updates on the ui changes and changed into doormile

This commit is contained in:
2026-08-24 18:10:47 +05:30
parent dcf9770ada
commit aa42d9ee81
409 changed files with 49950 additions and 66532 deletions

View File

@@ -1,14 +1,26 @@
import React, { createContext, useState, useContext, useEffect } from 'react';
import { base44 } from '@/api/base44Client';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import {
DOORMILE_TOKEN_KEY,
DOORMILE_USER_KEY,
loginAdmin,
logoutAdmin,
readStoredToken,
readStoredUser,
} from '@/api/doormile';
/**
* Auth context.
* Auth for the Doormile Express console.
*
* The reference app resolves an identity provider before rendering. The demo
* has no provider, so it resolves the seeded user immediately — but it keeps
* the same context shape (`isLoadingAuth`, `isLoadingPublicSettings`,
* `authError`, `navigateToLogin`, …) so `App.jsx`, `ProtectedRoute` and every
* consumer stay unchanged.
* `POST /admin/login` returns a JWT and the admin record, and every subsequent
* request carries that token — so the session lives entirely in localStorage
* and there is nothing to fetch on boot. The provider still resolves
* asynchronously (`isLoadingAuth`) rather than reading storage inline, because
* rendering a route tree before the token is known flashes the login page at an
* already-signed-in operator on every refresh.
*
* The axios client clears the same two keys and hard-navigates to `/login` on a
* 401, so an expired token ends the session the same way signing out does — a
* shell that renders but 401s on every fetch is worse than being sent back.
*/
const AuthContext = createContext(/** @type {any} */ (null));
@@ -19,55 +31,86 @@ export const AuthProvider = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoadingAuth, setIsLoadingAuth] = useState(true);
const [authChecked, setAuthChecked] = useState(false);
const [authError, setAuthError] = useState(null);
const checkUserAuth = async () => {
const checkUserAuth = useCallback(() => {
setIsLoadingAuth(true);
try {
const currentUser = await base44.auth.me();
setUser(currentUser);
setIsAuthenticated(true);
} catch {
setIsAuthenticated(false);
} finally {
setIsLoadingAuth(false);
setAuthChecked(true);
}
};
const token = readStoredToken();
const stored = readStoredUser();
setIsAuthenticated(Boolean(token));
setUser(token ? stored : null);
setIsLoadingAuth(false);
setAuthChecked(true);
}, []);
useEffect(() => {
checkUserAuth();
}, [checkUserAuth]);
/* A sign-out in another tab has to end the session in this one too —
otherwise this tab keeps a rendered console around a token that is gone. */
useEffect(() => {
const onStorage = (event) => {
if (event.key === DOORMILE_TOKEN_KEY || event.key === DOORMILE_USER_KEY) checkUserAuth();
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, [checkUserAuth]);
/**
* Signs in. Resolves to the API's own `{ success, message }` rather than
* throwing on a refused credential, so the login form can show the server's
* wording instead of a generic failure.
*/
const login = useCallback(async (email, password) => {
setAuthError(null);
try {
const result = await loginAdmin(email, password);
if (result?.success) {
setUser(result.user ?? readStoredUser());
setIsAuthenticated(true);
return { success: true };
}
const message = result?.message || 'Those credentials were not accepted';
setAuthError(message);
return { success: false, message };
} catch (err) {
const message = err?.message || 'Could not reach the Doormile API';
setAuthError(message);
return { success: false, message };
}
}, []);
const logout = (shouldRedirect = true) => {
const logout = useCallback((shouldRedirect = true) => {
logoutAdmin();
setUser(null);
setIsAuthenticated(false);
base44.auth.logout(shouldRedirect ? '/' : undefined);
};
if (shouldRedirect) window.location.replace('/login');
}, []);
const navigateToLogin = () => {
base44.auth.redirectToLogin();
};
const navigateToLogin = useCallback(() => window.location.replace('/login'), []);
return (
<AuthContext.Provider
value={{
user,
isAuthenticated,
isLoadingAuth,
// No remote app settings to fetch in the demo.
isLoadingPublicSettings: false,
authError: null,
appPublicSettings: null,
authChecked,
logout,
navigateToLogin,
checkUserAuth,
checkAppState: checkUserAuth,
}}
>
{children}
</AuthContext.Provider>
const value = useMemo(
() => ({
user,
isAuthenticated,
isLoadingAuth,
/* No remote app settings on this backend — kept so consumers that gate on
it stay unchanged. */
isLoadingPublicSettings: false,
appPublicSettings: null,
authError,
authChecked,
login,
logout,
navigateToLogin,
checkUserAuth,
checkAppState: checkUserAuth,
}),
[user, isAuthenticated, isLoadingAuth, authError, authChecked, login, logout, navigateToLogin, checkUserAuth]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = () => {

View File

@@ -1,75 +1,42 @@
import { useLocation } from 'react-router-dom';
import { base44 } from '@/api/base44Client';
import { useQuery } from '@tanstack/react-query';
import { useLocation, useNavigate } from 'react-router-dom';
import { Home } from 'lucide-react';
import { Button, EmptyState, Surface } from '@/components/ds';
import { useAuth } from '@/lib/AuthContext';
/**
* The 404 page.
*
* Signed-in operators get a second line naming the route table, because a
* missing route in a console is nearly always a link that was renamed rather
* than a page that never existed.
*/
export default function PageNotFound() {
const location = useLocation();
const navigate = useNavigate();
const { isAuthenticated } = useAuth();
export default function PageNotFound({}) {
const location = useLocation();
const pageName = location.pathname.substring(1);
const path = location.pathname;
const { data: authData, isFetched } = useQuery({
queryKey: ['user'],
queryFn: async () => {
try {
const user = await base44.auth.me();
return { user, isAuthenticated: true };
} catch (error) {
return { user: null, isAuthenticated: false };
}
}
});
return (
<div className="min-h-screen flex items-center justify-center p-6 bg-slate-50">
<div className="max-w-md w-full">
<div className="text-center space-y-6">
{/* 404 Error Code */}
<div className="space-y-2">
<h1 className="text-7xl font-light text-slate-300">404</h1>
<div className="h-0.5 w-16 bg-slate-200 mx-auto"></div>
</div>
{/* Main Message */}
<div className="space-y-3">
<h2 className="text-2xl font-medium text-slate-800">
Page Not Found
</h2>
<p className="text-slate-600 leading-relaxed">
The page <span className="font-medium text-slate-700">"{pageName}"</span> could not be found in this application.
</p>
</div>
{/* Admin Note */}
{isFetched && authData.isAuthenticated && authData.user?.role === 'admin' && (
<div className="mt-8 p-4 bg-slate-100 rounded-lg border border-slate-200">
<div className="flex items-start space-x-3">
<div className="flex-shrink-0 w-5 h-5 rounded-full bg-orange-100 flex items-center justify-center mt-0.5">
<div className="w-2 h-2 rounded-full bg-orange-400"></div>
</div>
<div className="text-left space-y-1">
<p className="text-sm font-medium text-slate-700">Admin Note</p>
<p className="text-sm text-slate-600 leading-relaxed">
This route is not registered in the demo. Check the route table in src/App.jsx.
</p>
</div>
</div>
</div>
)}
{/* Action Button */}
<div className="pt-6">
<button
onClick={() => window.location.href = '/'}
className="inline-flex items-center px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-200 rounded-lg hover:bg-slate-50 hover:border-slate-300 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-500"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Go Home
</button>
</div>
</div>
</div>
</div>
)
}
return (
<main className="flex min-h-screen items-center justify-center p-6">
<Surface radius="xl" padding="lg" className="w-full max-w-md">
<EmptyState
icon={Home}
title="Page not found"
description={`Nothing is registered at "${path}".`}
action={
<Button onClick={() => navigate(isAuthenticated ? '/doormile/dispatch' : '/login')}>
{isAuthenticated ? 'Back to Dispatch' : 'Sign in'}
</Button>
}
/>
{isAuthenticated && (
<p className="mt-2 text-center text-caption text-ink-4">
If this address used to work, check the route table in <code>src/App.jsx</code>.
</p>
)}
</Surface>
</main>
);
}

View File

@@ -1,103 +0,0 @@
/**
* Out-of-pattern activity, detected in one place.
*
* This computation used to live inside `buildFacts`, which was right when the
* assistant's fact sheet was its only reader. It now has a second: a declared
* data source resolves `activity.signals` for a card or an answer, and
* `dataResolver.js` cannot import from `components/ai-assistant/` without
* inverting the layering — a library reaching up into the component tree.
*
* So it moved down here, **unchanged**. `insights.js` imports it back and calls
* it exactly where the inline version used to run, which is what keeps
* `buildFacts` returning byte-identical output. "Two unusual patterns" has to
* mean the same two everywhere it is said, and the only way to guarantee that
* is for there to be one function saying it.
*
* Each signal is a deviation from this platform's own baseline, not a verdict:
* on a live deployment most of them resolve to an integration or a busy
* afternoon, and the copy that renders them says so.
*/
/** Share as a whole percentage, or 0 when there is nothing to be a share of. */
const pct = (n, d) => (d ? Math.round((n / d) * 100) : 0);
const DAY_MS = 1000 * 60 * 60 * 24;
/**
* Events an auditor looks at first: they change who is employed or what is
* being hired for. Re-exported by `insights.js`, which is where the rest of the
* product already imports it from.
*/
export const PRIVILEGED_EVENTS = ['hire_candidate', 'create_position'];
/**
* @param {any[]} activity The audit trail, newest first.
* @param {Date} today Injected rather than read from the clock, so the
* answer is stable for a given fact sheet.
*/
export function activitySignals(activity = [], today = new Date()) {
const since = (days) => {
const cutoff = today.getTime() - days * DAY_MS;
return activity.filter((e) => new Date(e.created_date).getTime() >= cutoff);
};
const privileged = activity.filter((e) => PRIVILEGED_EVENTS.includes(e.event_type));
const perUser = activity.reduce((acc, e) => {
acc[e.user_email] ||= { email: e.user_email, name: e.user_name, count: 0, privileged: 0 };
acc[e.user_email].count += 1;
if (PRIVILEGED_EVENTS.includes(e.event_type)) acc[e.user_email].privileged += 1;
return acc;
}, {});
const accounts = Object.values(perUser).sort((a, b) => b.count - a.count);
const busiest = accounts[0] || null;
const busiestShare = busiest ? pct(busiest.count, activity.length) : 0;
/* A burst is more than three actions from one account inside one hour. */
const perAccountHour = activity.reduce((acc, e) => {
const key = `${e.user_email}|${String(e.created_date).slice(0, 13)}`;
acc[key] = (acc[key] || 0) + 1;
return acc;
}, {});
const bursts = Object.values(perAccountHour).filter((n) => n > 3).length;
const offHours = activity.filter((e) => {
const hour = new Date(e.created_date).getHours();
return hour < 6 || hour >= 22;
});
const privilegedShare = pct(privileged.length, activity.length);
/* The flags, in the order they are worth reading. A count of these is what
the greeting reports, so anything added here changes that number. */
const flags = [
activity.length > 0 && busiestShare >= 50 && 'concentration',
bursts > 0 && 'burst',
offHours.length > 0 && 'off-hours',
activity.length > 0 && since(1).length === 0 && 'silent',
privilegedShare > 30 && 'privileged-share',
].filter(Boolean);
return {
accounts,
busiest,
busiestShare,
bursts,
offHours,
privileged,
privilegedShare,
flags,
};
}
/** What each flag means, for a reader who is being shown one. */
export const SIGNAL_LABELS = {
concentration: 'Most activity comes from one account',
burst: 'More than three actions from one account inside an hour',
'off-hours': 'Activity outside working hours',
silent: 'No activity in the last 24 hours',
'privileged-share': 'An unusually high share of privileged actions',
};
export const signalLabel = (flag) => SIGNAL_LABELS[flag] || flag;

View File

@@ -1,28 +0,0 @@
/**
* The administrator's permission scopes.
*
* Lives outside the page because two things now read it: the Profile page
* renders it, and Owliver answers "what are my permissions?" from it. One list,
* so the panel can never describe a scope the page does not show.
*/
export const PERMISSIONS = [
{ scope: 'Positions', level: 'Full access', detail: 'Create, edit, pause and close roles' },
{ scope: 'Candidates', level: 'Full access', detail: 'Screen, advance, decline and hire' },
{ scope: 'Talent Pool', level: 'Full access', detail: 'Search, contact and shortlist' },
{ scope: 'Analytics', level: 'Read only', detail: 'View reports and export' },
{ scope: 'Audit log', level: 'Read only', detail: 'View all platform activity' },
{ scope: 'Billing', level: 'No access', detail: 'Restricted to account owners' },
];
export const LEVEL_TONE = { 'Full access': 'success', 'Read only': 'info', 'No access': 'neutral' };
/** What the Profile page can actually do, for the assistant to point at. */
export const PROFILE_ACTIONS = [
{ label: 'Edit profile', detail: 'Change your display name' },
{ label: 'Change password', detail: 'Rotate your administrator password' },
{ label: 'Manage sessions', detail: 'Review devices signed in to this account' },
{ label: 'Two-factor authentication', detail: 'Required for privileged actions' },
{ label: 'Owliver workspace', detail: 'Open the contextual panel by default on supported pages' },
{ label: 'Compact density', detail: 'Tighter row heights across Admin tables' },
{ label: 'Email digest', detail: 'A daily summary of what needs attention' },
];

View File

@@ -1,127 +0,0 @@
/**
* Derived hiring facts for one position.
*
* Kept out of the components because the card, the drawer and the summary line
* all need the same numbers, and three independent derivations is how a card ends
* up disagreeing with the drawer it opens.
*/
const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired'];
/** Everyone who reached this stage or went past it. */
const atOrBeyond = (applications, stage) => {
const from = STAGE_ORDER.indexOf(stage);
return applications.filter((a) => STAGE_ORDER.indexOf(a.status) >= from);
};
/**
* Hiring health.
*
* Three states, and the thresholds are chosen so the label means something an
* operator would act on rather than describing the shape of the data:
*
* - **At risk** — nothing to hire from. No applicants at all, or applicants but
* nobody who clears the bar.
* - **Needs attention** — supply exists but is stuck: a screening backlog, or a
* role with candidates and no shortlist.
* - **Strong** — moving.
*/
function healthFor({ applied, unscreened, qualified, shortlisted, hired, status }) {
if (status !== 'active') {
return { label: 'Paused', tone: 'neutral', note: 'Not currently hiring.' };
}
if (!applied) {
return { label: 'At risk', tone: 'risk', note: 'No applicants yet.' };
}
/* Order matters here. "Nobody clears the bar" is only a safe conclusion once
everyone has actually been looked at — with applications still unscreened it
would be reporting a gap in our own process as a verdict on the candidates. */
if (unscreened > 0) {
return { label: 'Needs attention', tone: 'warning', note: `${unscreened} awaiting screening.` };
}
if (!qualified && !hired) {
return { label: 'At risk', tone: 'risk', note: 'Everyone screened, nobody clears the bar.' };
}
if (!shortlisted && !hired && applied >= 3) {
return { label: 'Needs attention', tone: 'warning', note: 'Screened but nobody shortlisted.' };
}
return { label: 'Strong', tone: 'success', note: 'Pipeline is moving.' };
}
/**
* The one thing worth saying about this position.
*
* Deliberately one. A card that lists every observation makes the reader do the
* triage the card was supposed to do for them, so this returns the most
* actionable finding and nothing else.
*/
function insightFor({ applied, unscreened, readyForInterview, coverage, hired, shortlisted, atRiskCount }) {
if (!applied) return 'No applicants yet — check the pay band and where this is posted.';
if (readyForInterview) {
return `${readyForInterview} candidate${readyForInterview === 1 ? '' : 's'} ready for interview`;
}
if (unscreened) {
return `${unscreened} candidate${unscreened === 1 ? '' : 's'} awaiting review`;
}
if (atRiskCount) {
return `${atRiskCount} candidate${atRiskCount === 1 ? '' : 's'} at risk — no credentials on file`;
}
if (coverage < 100) return `Screening coverage ${coverage}%`;
if (hired) return `${hired} hired${shortlisted > hired ? ` · ${shortlisted - hired} still shortlisted` : ''}`;
return 'Fully screened and awaiting a decision';
}
/** Builds one position row: the posting plus everything derived from its applicants. */
export function buildPosition(posting, applications) {
const apps = applications.filter((a) => a.job_posting_id === posting.id);
const applied = apps.length;
const unscreened = apps.filter((a) => a.status === 'applied').length;
const screened = atOrBeyond(apps, 'ai_screened').length;
const shortlisted = atOrBeyond(apps, 'shortlisted').length;
const interviews = atOrBeyond(apps, 'interview').length;
const hired = apps.filter((a) => a.status === 'hired').length;
const scored = apps.filter((a) => a.ai_score > 0);
const qualified = scored.filter((a) => a.ai_score >= 70).length;
const coverage = applied ? Math.round((scored.length / applied) * 100) : 0;
const avgScore = scored.length
? Math.round(scored.reduce((sum, a) => sum + a.ai_score, 0) / scored.length)
: 0;
/* Screened, strong, and not yet moved — the people a decision is owed to. */
const readyForInterview = apps.filter(
(a) => a.ai_score >= 70 && ['ai_screened', 'shortlisted'].includes(a.status)
).length;
const atRiskCount = scored.filter((a) => !(a.certifications || []).length).length;
const stats = {
applied, unscreened, screened, shortlisted, interviews, hired,
qualified, coverage, avgScore, readyForInterview, atRiskCount,
};
return {
...posting,
applications: apps,
stats,
health: healthFor({ ...stats, status: posting.status }),
insight: insightFor(stats),
/* The funnel, for the progression strip. Counts are cumulative, so each step
is a subset of the one before it and the bar never grows as it advances. */
progression: [
{ key: 'applied', label: 'Applied', count: applied },
{ key: 'screened', label: 'Screened', count: screened },
{ key: 'shortlisted', label: 'Shortlisted', count: shortlisted },
{ key: 'interview', label: 'Interviewed', count: interviews },
{ key: 'hired', label: 'Hired', count: hired },
],
topCandidates: [...apps]
.filter((a) => a.ai_score > 0)
.sort((a, b) => b.ai_score - a.ai_score)
.slice(0, 4),
};
}
/** Formats a pay band, or says plainly that there is not one. */
export const payLabel = (p) =>
p.pay_range_max ? `$${p.pay_range_min}–$${p.pay_range_max}/hr` : 'Pay not set';

View File

@@ -1,50 +0,0 @@
/**
* Admin sign-in state.
*
* The demo's data client (`base44.auth`) reports the seeded user as permanently
* signed in — there is no identity provider behind it. That is fine for the
* Employer and Talent demos, but the Admin console is supposed to be reached
* through its own sign-in, and "always authenticated" would mean /admin opened
* straight into the Control Center with the login page never seen.
*
* So Admin keeps a session marker of its own. It is deliberately thin: a single
* flag, set by the login page and read by the route guard. It is not security —
* a browser flag never is — it is the state a real session would occupy, put in
* the one place that both the guard and the login screen can agree on, so the
* flow is right now and swapping in a real provider later means changing this
* file and nothing else.
*
* `sessionStorage`, not `localStorage`: closing the tab should end the session,
* which is both the more defensible default for an administrative console and
* what makes the login page the app's actual starting point.
*/
const SESSION_KEY = 'krow_admin_session';
/** True when this tab has signed in to the Admin console. */
export function hasAdminSession() {
try {
return sessionStorage.getItem(SESSION_KEY) === '1';
} catch {
// Private mode with storage denied: fail closed, so the login page shows.
return false;
}
}
/** Records a successful sign-in. */
export function startAdminSession() {
try {
sessionStorage.setItem(SESSION_KEY, '1');
} catch {
// Nothing to do — the guard will simply ask for sign-in again.
}
}
/** Clears the session. Used by sign-out. */
export function endAdminSession() {
try {
sessionStorage.removeItem(SESSION_KEY);
} catch {
// Ignore.
}
}

View File

@@ -1,313 +0,0 @@
import { canonicalPage } from '@/lib/skills/surfaces';
import { slugify } from '@/lib/skills/uiConfig';
import {
AGENT_ACCESS, AGENT_STATUSES, DEFAULT_AGENT_ACCESS, DEFAULT_AGENT_ICON,
DEFAULT_AGENT_STATUS, DEFAULT_KNOWLEDGE_KIND, DEFAULT_PERMISSION_ROLE, DEFAULT_REASONING,
KNOWLEDGE_KINDS, PERMISSION_ROLES, SUPPORTED_REASONING, isAgentIcon, reasoningFor,
} from './vocabulary';
/**
* An agent definition's frontmatter, checked and normalized.
*
* The same contract `normalizeSkillOwliver` holds, for the same reasons:
*
* - **Everything is optional.** A definition that declares only an id and a
* name normalizes to a working agent with documented defaults. That is what
* lets the five zero-skill pages have real agents without inventing skills
* to fill them.
* - **Nothing unknown survives.** Statuses, reasoning modes, pages, icons,
* knowledge kinds and permission roles are checked against the closed
* tables in `vocabulary.js`; an unrecognised value is a named error rather
* than a dropped key.
* - **What validates is kept.** One bad entry costs its author that entry and
* a message, never the rest of the file.
*
* One rule is deliberately *absent*: an agent with no skills is not refused.
* A skill with no capabilities genuinely cannot answer, which is why the skill
* validator refuses one — but an agent with no skills still has its page's own
* responder, which is how Control Center, Hired History, Talent Pool, Activity
* and Profile answer today. Refusing them would force placeholder skills into
* the registry to make the UI look complete, and a registry that lies about
* what exists is worse than a short list.
*/
/** What a definition that declares nothing gets. */
export const NO_PERMISSIONS = Object.freeze({
owner: '',
access: DEFAULT_AGENT_ACCESS,
people: [],
});
const asList = (value) => {
if (Array.isArray(value)) return value;
if (value === null || value === undefined || value === '') return [];
return [value];
};
const trimmed = (value) => String(value ?? '').trim();
/** Deduped, order preserved — the order an author wrote is the order shown. */
function uniqueStrings(raw, { where, errors, label }) {
const seen = new Set();
const out = [];
asList(raw).forEach((entry, index) => {
const value = trimmed(entry);
if (!value) {
errors.push(`${where}[${index}]: ${label} cannot be blank.`);
return;
}
if (seen.has(value)) return;
seen.add(value);
out.push(value);
});
return out;
}
/**
* The pages this agent covers, as canonical surface keys.
*
* Through `canonicalPage`, so a definition may write an alias — `university`
* for `krow-forge` — exactly as a skill may, and the two vocabularies cannot
* drift apart. An unknown page is an error rather than a silently dropped
* entry, because a page nobody recognises is an agent that will never appear
* anywhere and give no reason why.
*/
function normalizePages(raw, { errors }) {
const seen = new Set();
const pages = [];
asList(raw).forEach((entry, index) => {
const written = trimmed(entry);
if (!written) {
errors.push(`pages[${index}]: a page cannot be blank.`);
return;
}
const canonical = canonicalPage(written);
if (!canonical) {
errors.push(`pages[${index}]: \`${written}\` is not a page this product has.`);
return;
}
if (seen.has(canonical)) return;
seen.add(canonical);
pages.push(canonical);
});
return pages;
}
/**
* One conversation starter, in either the plain-string or the mapping form.
*
* The same two shapes `normalizeSuggestion` accepts for skills, so an author
* who has written one has already written the other.
*/
function normalizeStarter(raw, { errors, index }) {
const where = `starters[${index}]`;
if (typeof raw === 'string' || typeof raw === 'number') {
const label = trimmed(raw);
if (!label) {
errors.push(`${where}: a starter needs text.`);
return null;
}
return { label, prompt: label };
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
errors.push(`${where}: a starter must be a line of text, or a mapping of options.`);
return null;
}
const label = trimmed(raw.label ?? raw.prompt);
if (!label) {
errors.push(`${where}: a starter needs a \`label\`.`);
return null;
}
/* A starter with no prompt of its own asks what it says. */
return { label, prompt: trimmed(raw.prompt) || label };
}
/**
* One knowledge entry.
*
* Modelled as a document with an id and a body even though it is one authored
* note today, because that is the shape a retrieval layer reads — see
* `knowledge.js`. Getting the shape right now is what makes a later move to a
* real store a change of transport rather than a change of format.
*/
function normalizeKnowledge(raw, { errors, index }) {
const where = `knowledge[${index}]`;
if (typeof raw === 'string' || typeof raw === 'number') {
const body = trimmed(raw);
if (!body) {
errors.push(`${where}: a knowledge entry needs text.`);
return null;
}
return { id: slugify(body.slice(0, 40)) || `k${index + 1}`, label: body.slice(0, 60), kind: DEFAULT_KNOWLEDGE_KIND, body, url: '' };
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
errors.push(`${where}: a knowledge entry must be a line of text, or a mapping of options.`);
return null;
}
const label = trimmed(raw.label);
const body = trimmed(raw.body);
const url = trimmed(raw.url);
if (!label && !body) {
errors.push(`${where}: a knowledge entry needs a \`label\` or a \`body\`.`);
return null;
}
const kind = trimmed(raw.kind) || DEFAULT_KNOWLEDGE_KIND;
if (!KNOWLEDGE_KINDS.includes(kind)) {
errors.push(`${where}: \`${kind}\` is not a knowledge kind. Use one of ${KNOWLEDGE_KINDS.join(', ')}.`);
return null;
}
if (kind === 'link' && !url) {
errors.push(`${where}: a \`link\` needs a \`url\`.`);
return null;
}
return {
id: trimmed(raw.id) || slugify(label) || `k${index + 1}`,
label: label || body.slice(0, 60),
kind,
body,
url,
};
}
/** Who owns the agent, who may reach it, and what they may do. */
function normalizePermissions(raw, { errors }) {
if (raw === null || raw === undefined) return { ...NO_PERMISSIONS };
if (typeof raw !== 'object' || Array.isArray(raw)) {
errors.push('permissions: must be a mapping of `owner`, `access` and `people`.');
return { ...NO_PERMISSIONS };
}
const access = trimmed(raw.access) || DEFAULT_AGENT_ACCESS;
if (!AGENT_ACCESS.includes(access)) {
errors.push(`permissions.access: \`${access}\` is not an access mode. Use one of ${AGENT_ACCESS.join(', ')}.`);
}
const people = [];
asList(raw.people).forEach((entry, index) => {
const where = `permissions.people[${index}]`;
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
errors.push(`${where}: must be a mapping of \`user\` and \`role\`.`);
return;
}
const user = trimmed(entry.user);
if (!user) {
errors.push(`${where}: needs a \`user\`.`);
return;
}
const role = trimmed(entry.role) || DEFAULT_PERMISSION_ROLE;
if (!PERMISSION_ROLES.includes(role)) {
errors.push(`${where}: \`${role}\` is not a role. Use one of ${PERMISSION_ROLES.join(', ')}.`);
return;
}
people.push({ user, role });
});
return {
owner: trimmed(raw.owner),
access: AGENT_ACCESS.includes(access) ? access : DEFAULT_AGENT_ACCESS,
people,
};
}
/**
* One agent's frontmatter → `{ agent, errors }`.
*
* `agentId` is the id already derived by the caller, used to reject an agent
* that names itself as its own subagent — a cycle the runtime would otherwise
* have to defend against on every turn.
*/
export function normalizeAgent(raw, { agentId = '', body = '' } = {}) {
const errors = [];
const data = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
if (raw && (typeof raw !== 'object' || Array.isArray(raw))) {
errors.push('An agent definition must be a mapping of options.');
}
const status = trimmed(data.status) || DEFAULT_AGENT_STATUS;
if (!AGENT_STATUSES.includes(status)) {
errors.push(`status: \`${status}\` is not a status. Use one of ${AGENT_STATUSES.join(', ')}.`);
}
const reasoning = trimmed(data.reasoning) || DEFAULT_REASONING;
if (!reasoningFor(reasoning)) {
errors.push(`reasoning: \`${reasoning}\` is not a reasoning mode. Use one of ${SUPPORTED_REASONING.join(', ')}.`);
}
const icon = trimmed(data.icon) || DEFAULT_AGENT_ICON;
if (!isAgentIcon(icon)) {
errors.push(`icon: \`${icon}\` is not an icon this product has.`);
}
/* A version is an integer that only ever goes up. Anything else is an
authoring slip, and reading it as 1 is kinder than refusing the file —
but it is still reported, because a definition that thinks it is v3 and
registers as v1 will publish over something. */
let version = 1;
if (data.version !== undefined && data.version !== null && data.version !== '') {
const parsed = Number(data.version);
if (!Number.isInteger(parsed) || parsed < 1) {
errors.push(`version: \`${data.version}\` is not a whole number of 1 or more.`);
} else {
version = parsed;
}
}
const subagents = uniqueStrings(data.subagents, {
where: 'subagents', errors, label: 'a subagent id',
}).filter((id) => {
if (agentId && id === agentId) {
errors.push('subagents: an agent cannot be its own subagent.');
return false;
}
return true;
});
const starters = asList(data.starters)
.map((entry, index) => normalizeStarter(entry, { errors, index }))
.filter(Boolean);
const knowledge = asList(data.knowledge)
.map((entry, index) => normalizeKnowledge(entry, { errors, index }))
.filter(Boolean);
return {
agent: {
status: AGENT_STATUSES.includes(status) ? status : DEFAULT_AGENT_STATUS,
version,
/* When to reach for this agent, in the author's words. Shown in the
switcher and carried to the runtime; never matched on, so it can be
prose rather than keywords. */
trigger: trimmed(data.trigger),
reasoning: reasoningFor(reasoning) ? reasoning : DEFAULT_REASONING,
icon: isAgentIcon(icon) ? icon : DEFAULT_AGENT_ICON,
webSearch: data.webSearch === true || data.web_search === true,
pages: normalizePages(data.pages, { errors }),
skills: uniqueStrings(data.skills, { where: 'skills', errors, label: 'a skill id' }),
subagents,
knowledge,
starters,
permissions: normalizePermissions(data.permissions, { errors }),
/* The body's own sections, read by the caller and passed through here so
one record carries everything a definition said. */
instructions: trimmed(body),
},
errors,
};
}

View File

@@ -1,209 +0,0 @@
import { REMOVE, patchFrontmatter } from '@/lib/skills/skillFields';
import { canonicalPage } from '@/lib/skills/surfaces';
import { parseAgent } from './registry';
/**
* An agent definition ⇄ the fields an editor shows.
*
* Two directions, one field set. `agentFieldsFromSource` reads a definition
* into the form; `agentPatch` writes the form back as a frontmatter patch.
* Both name the same keys, so a field cannot exist in one direction only —
* which is the bug that made the skill editors' upload path silently edit
* nothing.
*
* The patch is consumed by the **existing** `patchFrontmatter`. There is no
* second writer: `writeBlock` already emits nested block maps and
* `- key: value` sequences to any depth, and `yaml.js` reads them back, so
* `permissions.people` and `knowledge` round-trip with no change to either.
* That claim is asserted in `skill-check.mjs` rather than assumed.
*/
/** What a fresh editor screen holds. */
export const EMPTY_AGENT_FIELDS = Object.freeze({
id: '',
name: '',
description: '',
icon: '',
trigger: '',
status: 'draft',
version: 1,
reasoning: 'balanced',
webSearch: false,
pages: [],
skills: [],
subagents: [],
knowledge: [],
starters: [],
instructions: '',
permissions: { owner: '', access: 'all', people: [] },
});
/**
* A definition, as fields.
*
* Reads through `parseAgent`, so the form is filled from exactly what the
* runtime will see rather than from a second reading of the same text.
*/
export function agentFieldsFromSource(source) {
let agent;
try {
agent = parseAgent(source, { custom: true });
} catch {
/* A half-typed definition fills nothing rather than emptying fields that
are already filled in. The caller decides what to do about that. */
return { ...EMPTY_AGENT_FIELDS };
}
return {
id: agent.id || '',
name: agent.name === 'Untitled agent' ? '' : agent.name,
description: agent.description || '',
icon: agent.icon || '',
trigger: agent.trigger || '',
status: agent.status,
version: agent.version,
reasoning: agent.reasoning,
webSearch: agent.webSearch,
pages: [...agent.pages],
skills: [...agent.skills],
subagents: [...agent.subagents],
knowledge: agent.knowledge.map((k) => ({ ...k })),
starters: agent.starters.map((s) => ({ ...s })),
instructions: agent.instructions || '',
permissions: {
owner: agent.permissions.owner,
access: agent.permissions.access,
people: agent.permissions.people.map((p) => ({ ...p })),
},
};
}
/** An empty list clears the key rather than writing `key:` with nothing under it. */
const listOrRemove = (list) => (list && list.length ? list : REMOVE);
/**
* The frontmatter a set of fields means.
*
* `undefined` leaves a key exactly as the author wrote it — so an editor that
* only knows about four fields cannot erase the other ten, and a definition
* hand-written with comments and key order survives being saved from the form.
*/
export function agentPatch(fields = {}) {
const patch = {};
const scalar = (key, value) => {
if (value === undefined) return;
patch[key] = value === '' ? REMOVE : value;
};
scalar('id', fields.id);
scalar('name', fields.name);
scalar('description', fields.description);
scalar('icon', fields.icon);
scalar('trigger', fields.trigger);
scalar('status', fields.status);
if (fields.version !== undefined) patch.version = fields.version;
scalar('reasoning', fields.reasoning);
if (fields.webSearch !== undefined) patch.webSearch = Boolean(fields.webSearch);
/* Pages are canonicalized on the way out, so a definition saved from the
form names surfaces the way the vocabulary does — an author may still
write an alias by hand, and it will still read. */
if (fields.pages !== undefined) {
patch.pages = listOrRemove(
(fields.pages || []).map((p) => canonicalPage(p) || p).filter(Boolean)
);
}
if (fields.skills !== undefined) patch.skills = listOrRemove(fields.skills);
if (fields.subagents !== undefined) patch.subagents = listOrRemove(fields.subagents);
if (fields.starters !== undefined) {
patch.starters = listOrRemove(
(fields.starters || []).map((s) =>
/* A starter that asks what it says is one line, not two. */
(s.prompt && s.prompt !== s.label ? { label: s.label, prompt: s.prompt } : { label: s.label })
)
);
}
if (fields.knowledge !== undefined) {
patch.knowledge = listOrRemove(
(fields.knowledge || []).map((k) => ({
id: k.id || undefined,
label: k.label || undefined,
kind: k.kind || undefined,
body: k.body || undefined,
url: k.url || undefined,
}))
);
}
if (fields.permissions !== undefined) {
const { owner, access, people } = fields.permissions || {};
patch.permissions = {
owner: owner || undefined,
access: access || undefined,
people: people && people.length
? people.map((p) => ({ user: p.user, role: p.role }))
: undefined,
};
}
return patch;
}
/**
* Replaces the prose under a `## Heading`, keeping everything around it.
*
* Instructions are the one configurable thing that does **not** live in
* frontmatter: they are prose, and prose belongs under a heading where it can
* be written and read as prose. That means `agentPatch` alone cannot save them —
* it writes frontmatter, and an edit to instructions would be silently dropped.
*
* Matches the same section the parser reads (`sectionSource`), so what is
* written here is exactly what is read back. A definition with no such heading
* gains one rather than losing the edit.
*/
function writeSection(body, heading, text) {
const escaped = String(heading).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(`(^|\\n)##\\s+${escaped}\\s*\\n[\\s\\S]*?(?=\\n##\\s|$)`, 'i');
const content = String(text ?? '').trim();
if (pattern.test(body)) {
/* An emptied section is removed rather than left as a bare heading with
nothing under it, which reads as an author who meant to write something. */
return content
? body.replace(pattern, `$1## ${heading}\n\n${content}\n`)
: body.replace(pattern, '$1');
}
if (!content) return body;
/* Appended, because a definition that never had this section has no place
the author intended it to go. */
return `${body.trimEnd()}\n\n## ${heading}\n\n${content}\n`;
}
/**
* Fields, applied to a definition.
*
* The one place the two halves meet: frontmatter through the existing
* `patchFrontmatter`, and the prose sections through `writeSection`. A caller
* therefore never has to remember which half a given field lives in — which is
* exactly the mistake that made instructions silently unsaveable.
*/
export function applyAgentFields(source, fields = {}) {
const patched = patchFrontmatter(source, agentPatch(fields));
if (fields.instructions === undefined) return patched;
/* Split on the closing fence so the body can be rewritten without touching
the frontmatter that was just written. */
const match = /^---[ \t]*\n[\s\S]*?\n---[ \t]*\n?/.exec(patched);
if (!match) return patched;
const head = patched.slice(0, match[0].length);
const body = patched.slice(match[0].length);
return head + writeSection(body, 'Instructions', fields.instructions);
}

View File

@@ -1,83 +0,0 @@
import { patchFrontmatter } from '@/lib/skills/skillFields';
import { agentPatch } from './agentFields';
import { parseAgent } from './registry';
import { slugify } from '@/lib/skills/uiConfig';
/**
* Publishing, archiving and duplicating an agent.
*
* Pure functions over Markdown: each returns a new definition, and the caller
* decides whether to store it. Keeping them out of the components means the
* list and the detail page cannot implement "publish" two slightly different
* ways — the failure that produces an agent published from one screen and not
* the other.
*
* The one rule worth stating plainly: **publishing never silently overwrites a
* published version.** A draft taken from v1 and published while someone else
* moved the definition to v2 is a conflict, not a save. Returning it as a
* conflict lets the screen say so; overwriting would be indistinguishable from
* the other change never having been made.
*/
/**
* The definition, published.
*
* Returns `{ source }` when the publish is safe, or `{ conflict }` when the
* stored definition has moved past the version this draft was taken from.
*/
/** @param {string} source @param {any} [options] */
export function publishAgent(source, { publishedVersion = 0 } = {}) {
const agent = parseAgent(source, { custom: true });
if (publishedVersion && agent.version < publishedVersion) {
return {
conflict: {
draftVersion: agent.version,
publishedVersion,
message: `This draft was taken from v${agent.version}, but v${publishedVersion} is published. `
+ 'Publishing would discard that change.',
},
};
}
/* A first publish keeps its version; republishing an already-published
definition moves it on, so "what is live" is always a specific version. */
const next = agent.status === 'published' ? agent.version + 1 : Math.max(agent.version, 1);
return { source: patchFrontmatter(source, agentPatch({ status: 'published', version: next })) };
}
/** The definition, taken out of service. Its content is untouched. */
export const archiveAgent = (source) =>
patchFrontmatter(source, agentPatch({ status: 'archived' }));
/** The definition, put back into service as a draft rather than live. */
export const restoreAgent = (source) =>
patchFrontmatter(source, agentPatch({ status: 'draft' }));
/**
* A copy, under a new id.
*
* Always a draft at v1, whatever the original was: a duplicate of a published
* agent is a starting point, and inheriting `published` would put an unreviewed
* copy into the switcher the moment it was made.
*/
/** @param {string} source @param {any} [options] */
export function duplicateAgent(source, { name, existingIds = [] } = {}) {
const agent = parseAgent(source, { custom: true });
const copyName = name || `${agent.name} copy`;
/* An id nobody is using. Suffixed rather than randomised so the address stays
something a person can read and type. */
const base = slugify(copyName) || `${agent.id}-copy`;
let id = base;
let n = 2;
while (existingIds.includes(id)) {
id = `${base}-${n}`;
n += 1;
}
return patchFrontmatter(source, agentPatch({
id, name: copyName, status: 'draft', version: 1,
}));
}

View File

@@ -1,114 +0,0 @@
import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement';
import { pageKeyForContext, routeForPageKey } from '@/lib/skills/registry';
import { canonicalPage, surfaceFor } from '@/lib/skills/surfaces';
/**
* The context envelope — what the runtime is told about where the reader is.
*
* Composed from what already exists rather than replacing it. `PageContext.jsx`
* is untouched: it remains the one channel a page publishes its selection
* through, and this reads that channel and adds the address of the page around
* it. Two reasons that separation is worth keeping:
*
* - **The page channel is the boundary.** A page publishes records it already
* has, and nothing here reaches back into the page. Rebuilding it as an
* "agent context" would make the agent the thing that decides what is
* visible, which is exactly the inversion this design refuses.
* - **A page that publishes nothing still resolves.** Every field below has a
* defined empty value, so a page that has adopted none of the optional
* publishing conventions produces a valid envelope describing where the
* reader is and nothing more — identical behaviour to before this existed.
*
* @typedef {Object} OwliverContext
* @property {string} page The page's own label ("Positions")
* @property {string|null} pageKey Its surface key ("positions")
* @property {string|null} route The real Admin path
* @property {string|null} contextId The assistant context id
* @property {string|null} period The period the page is filtered to
* @property {Object} filters Whatever the page published as filters
* @property {any[]} selectedItems Records the page published as selected
* @property {string[]} visibleWidgets Section ids the page published as on screen
* @property {Object} metrics Figures the page has already computed
* @property {Object|null} position From the existing PageContext channel
* @property {Object|null} candidate From the existing PageContext channel
* @property {string[]} writableSources Sources this page accepts writes for
*/
/** What a page that publishes nothing produces. */
const EMPTY = Object.freeze({
filters: Object.freeze({}),
selectedItems: Object.freeze([]),
visibleWidgets: Object.freeze([]),
metrics: Object.freeze({}),
});
const asObject = (value) =>
value && typeof value === 'object' && !Array.isArray(value) ? value : {};
const asArray = (value) => (Array.isArray(value) ? value : []);
/**
* Builds the envelope for the page the reader is on.
*
* `route` is resolved through the existing placement table rather than being
* assembled from strings, so nothing here can name an address the product does
* not have.
*/
export function buildOwliverContext({
context = null,
pathname = '',
pageContext = {},
writableSources = [],
} = {}) {
const contextId = context?.id ?? null;
const pageKey = contextId ? pageKeyForContext(contextId) : null;
/* The path the reader is actually on wins; the placement table answers when
no path was handed over (a test, a background read). */
const declaredRoute = pageKey ? routeForPageKey(pageKey) : null;
const route = PLACEMENT_ROUTES[pathname] ? pathname : declaredRoute;
const published = asObject(pageContext);
return {
page: context?.page ?? (pageKey ? surfaceFor(pageKey)?.label ?? pageKey : ''),
pageKey: pageKey ? canonicalPage(pageKey) || pageKey : null,
route: route ?? null,
contextId,
/* The optional publishing convention. A page adopts as much of it as it
has, and a page that adopts none of it is not broken — it simply has no
filters, no selection and no widgets to declare. */
period: published.period ?? null,
filters: asObject(published.filters) === published.filters
? published.filters
: EMPTY.filters,
selectedItems: asArray(published.selectedItems),
visibleWidgets: asArray(published.visibleWidgets),
metrics: asObject(published.metrics),
/* The two records the existing channel already carries, named explicitly
because every source that needs one names one of them. */
position: published.position ?? null,
candidate: published.candidate ?? null,
/* What this page will accept a write for. A tool cannot write anywhere the
page has not offered — see `tools.js`. */
writableSources: asArray(writableSources),
};
}
/**
* The envelope, reduced to what is worth storing beside a conversation.
*
* Deliberately not the whole thing. `selectedItems` and `metrics` are records
* and computed figures; writing them into storage on every turn would put the
* dataset in localStorage a message at a time. What a reviewer needs later is
* *where* the question was asked, not a copy of what was on screen.
*/
export const storableContext = (envelope) => ({
page: envelope?.page ?? '',
pageKey: envelope?.pageKey ?? null,
route: envelope?.route ?? null,
period: envelope?.period ?? null,
});

View File

@@ -1,153 +0,0 @@
/**
* What the stored conversations add up to.
*
* Pure selectors over the archive `history.js` returns: no reading, no React,
* no knowledge of who is asking. Both the Insights view and the Conversation
* Reviews view read through here, so a figure quoted in one and a list shown in
* the other cannot describe different things.
*
* **Nothing is invented.** Every counter is derived from records that exist,
* and a workspace with no conversations returns `empty: true` rather than a row
* of zeros. A zero and an absence look identical on a dashboard and mean
* completely different things — one says the agent was asked and did nothing,
* the other says it has not been asked. The empty flag is what lets the view
* say which.
*/
const DAY = 24 * 60 * 60 * 1000;
const at = (record) => new Date(record?.updatedAt || 0).getTime();
/** Records for one agent, or all of them when no agent is named. */
export function conversationsForAgent(records = [], agentId = null) {
if (!agentId) return [...records];
return records.filter((record) => record.agentId === agentId);
}
/** Conversations nobody has rated yet — the queue a reviewer works through. */
export const unratedConversations = (records = [], agentId = null) =>
conversationsForAgent(records, agentId).filter((record) => !record.feedback);
/** Counts by a key each record contributes many of. */
function tally(records, pick) {
const counts = new Map();
for (const record of records) {
for (const value of pick(record) || []) {
if (!value) continue;
counts.set(value, (counts.get(value) || 0) + 1);
}
}
return [...counts.entries()]
.map(([id, count]) => ({ id, count }))
.sort((a, b) => b.count - a.count || a.id.localeCompare(b.id));
}
/** Counts by a key each record contributes one of. */
function tallyOne(records, pick) {
const counts = new Map();
for (const record of records) {
const value = pick(record);
if (!value) continue;
counts.set(value, (counts.get(value) || 0) + 1);
}
return [...counts.entries()]
.map(([id, count]) => ({ id, count }))
.sort((a, b) => b.count - a.count || a.id.localeCompare(b.id));
}
/**
* The figures an Insights view reports.
*
* `since` windows the archive; `agentId` narrows it to one agent. Both are
* optional, and neither invents a record that is not there.
*/
export function conversationStats(records = [], { agentId = null, since = null } = {}) {
const scoped = conversationsForAgent(records, agentId)
.filter((record) => (since ? at(record) >= new Date(since).getTime() : true));
if (!scoped.length) {
return {
empty: true,
total: 0,
turns: 0,
averageTurns: 0,
pages: 0,
days: 0,
activeDays: 0,
feedback: { up: 0, down: 0, unrated: 0, score: null },
byAgent: [],
byPage: [],
bySkill: [],
byTool: [],
byDay: [],
};
}
const turns = scoped.reduce((sum, record) => sum + (record.turns || 0), 0);
const up = scoped.filter((r) => r.feedback?.rating === 'up').length;
const down = scoped.filter((r) => r.feedback?.rating === 'down').length;
const rated = up + down;
/* Conversations per day, oldest first, over the days that actually have
one. Padding out empty days would draw a chart mostly made of zeros and
make a quiet week look like an outage. */
const perDay = new Map();
for (const record of scoped) {
const day = new Date(at(record));
day.setHours(0, 0, 0, 0);
const key = day.toISOString().slice(0, 10);
perDay.set(key, (perDay.get(key) || 0) + 1);
}
const byDay = [...perDay.entries()]
.map(([day, count]) => ({ id: day, day, count }))
.sort((a, b) => a.day.localeCompare(b.day));
const span = scoped.length
? Math.max(1, Math.round((Math.max(...scoped.map(at)) - Math.min(...scoped.map(at))) / DAY) + 1)
: 0;
return {
empty: false,
total: scoped.length,
turns,
averageTurns: Math.round((turns / scoped.length) * 10) / 10,
pages: new Set(scoped.map((r) => r.contextId).filter(Boolean)).size,
/* Days the archive spans, and days anything was actually asked. Reporting
only the first would make a busy afternoon look like a busy fortnight. */
days: span,
activeDays: byDay.length,
feedback: {
up,
down,
unrated: scoped.length - rated,
/* Null rather than 0 when nothing is rated: a score of zero reads as
unanimous disapproval. */
score: rated ? Math.round((up / rated) * 100) : null,
},
byAgent: tallyOne(scoped, (r) => r.agentId),
byPage: tallyOne(scoped, (r) => r.pageContext?.page || r.page),
bySkill: tally(scoped, (r) => r.skillsUsed),
byTool: tally(scoped, (r) => r.toolsUsed),
byDay,
};
}
/**
* One conversation, reduced to what a review list shows.
*
* The thread itself is deliberately not included: a list renders forty of these
* and only one is ever opened.
*/
export const reviewRow = (record) => ({
id: record.id,
title: record.title,
agentId: record.agentId ?? null,
page: record.pageContext?.page || record.page || '',
route: record.pageContext?.route ?? null,
turns: record.turns || 0,
skillsUsed: record.skillsUsed || [],
toolsUsed: record.toolsUsed || [],
feedback: record.feedback ?? null,
updatedAt: record.updatedAt,
});

View File

@@ -1,114 +0,0 @@
import { parseAgent } from './registry';
import { agentPatch } from './agentFields';
import { patchFrontmatter } from '@/lib/skills/skillFields';
/**
* Account-authored agents, as stored.
*
* An agent is its Markdown source and nothing else — the same artefact a file
* in `src/agents/` is, read back by the same parser. These helpers exist so
* every writer produces that one shape; two writers would be a second agent
* system by accident.
*
* Stored in `preferences.customAgents`, mirroring `customSkills`, which means
* an agent authored here can be copied into `src/agents/` later with no
* conversion — and that editing a shipped agent is the same act as editing a
* shipped skill: an account definition of the same id, reported as `shadowed`.
*/
/** The starting definition offered to an author. */
export function agentTemplate({
id = '', name = '', description = '', pages = [], skills = [], icon = '', trigger = '',
} = {}) {
const fallback = {
id: 'my-agent',
name: 'My Agent',
description: 'What this agent is for.',
};
const label = name || fallback.name;
/* Identity is written here; everything else is written by `agentPatch`, the
same writer the editor uses on an existing file. A template that composed
its own YAML would be a second field set, and the fields one knew about
would not be the fields the other did. */
const skeleton = `---
id: ${id || fallback.id}
name: ${name || fallback.name}
description: ${description || fallback.description}
---
# ${label}
## Instructions
Describe how this agent should answer: what it is responsible for, what it
should say when it cannot help, and how it should use the skills it carries.
## Purpose
- Describe one thing this agent is for.
- Add more as needed.
`;
return patchFrontmatter(
skeleton,
agentPatch({
id: id || fallback.id,
name: label,
description: description || fallback.description,
/* A fresh agent is a draft. Creating one must never publish it. */
status: 'draft',
version: 1,
icon,
trigger,
pages,
skills,
})
);
}
/** Parses a stored entry, tolerating the bare-string form. */
const sourceOf = (entry) => (typeof entry === 'string' ? entry : entry?.raw ?? '');
/**
* The stored list with `source` added or replaced.
*
* Matched by id, so editing an agent overwrites its own entry rather than
* adding a near-duplicate beside it.
*/
export function upsertCustomAgent(existing = [], source) {
const agent = parseAgent(source, { custom: true });
const rest = (existing || []).filter((entry) => {
try {
return parseAgent(sourceOf(entry), { custom: true }).id !== agent.id;
} catch {
/* An unparseable entry cannot be the one being edited, and dropping it
here would delete a definition its author may still want to fix. */
return true;
}
});
return { agent, next: [...rest, { path: `custom/${agent.id}.md`, raw: source }] };
}
/** The stored list without the agent of this id. */
export function removeCustomAgent(existing = [], id) {
return (existing || []).filter((entry) => {
try {
return parseAgent(sourceOf(entry), { custom: true }).id !== id;
} catch {
return true;
}
});
}
/** The stored Markdown for one agent, or null if the account has none. */
export function customAgentSource(existing = [], id) {
for (const entry of existing || []) {
try {
if (parseAgent(sourceOf(entry), { custom: true }).id === id) return sourceOf(entry);
} catch {
/* Unparseable entries are not the one being asked for. */
}
}
return null;
}

View File

@@ -1,157 +0,0 @@
import { agentCovers } from './runtime';
/**
* The knowledge seam.
*
* Boundary 5 of the architecture, established now and deliberately minimal.
* There is no vector store, no embedding model and no document corpus in this
* phase, and none is faked to make retrieval look implemented.
*
* What exists is real: an agent's authored `knowledge:` entries, chunked and
* matched on terms. Somebody wrote those entries, an answer that quotes one
* says where it came from, and an agent with none returns nothing rather than a
* plausible paragraph. That is a small capability honestly delivered, not a
* stub pretending to be retrieval.
*
* **The page boundary applies here exactly as it does to skills.** An agent
* that does not cover the current page retrieves nothing — otherwise knowledge
* would be the one door through which selecting an agent could reach material
* the page was not offering, which is the failure the whole design exists to
* prevent. Knowledge is scoped by the same rule as data and tools.
*
* The shape is what makes this replaceable rather than throwaway: passages come
* back as `{documentId, chunkId, text, score}`, which is what a retrieval layer
* returns. Moving to Postgres and pgvector later means reimplementing
* `retrieveKnowledge` behind the same signature — a change of transport, not a
* change of format, and the same discipline `base44Client.js` already applies
* to the data layer.
*/
/** Words too common to discriminate between one passage and another. */
const STOP_WORDS = new Set([
'the', 'a', 'an', 'and', 'or', 'but', 'is', 'are', 'was', 'were', 'be', 'been',
'to', 'of', 'in', 'on', 'at', 'for', 'with', 'by', 'from', 'as', 'that', 'this',
'it', 'its', 'we', 'our', 'us', 'you', 'your', 'i', 'me', 'my', 'what', 'which',
'who', 'when', 'where', 'how', 'why', 'do', 'does', 'did', 'can', 'could',
'should', 'would', 'will', 'about', 'says', 'say',
]);
const terms = (text) =>
String(text || '')
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((word) => word.length > 2 && !STOP_WORDS.has(word));
/**
* One knowledge entry, split into passages.
*
* By sentence, because a knowledge entry is prose and a sentence is the
* smallest piece of it that still means something on its own. A fixed-width
* chunker would cut mid-clause and quote half a rule, which is worse than not
* answering.
*/
function chunk(entry) {
const body = String(entry.body || '').trim();
if (!body) return [];
const sentences = body
.split(/(?<=[.!?])\s+/)
.map((s) => s.trim())
.filter(Boolean);
return (sentences.length ? sentences : [body]).map((text, i) => ({
documentId: entry.id,
chunkId: `${entry.id}#${i}`,
label: entry.label,
kind: entry.kind,
url: entry.url || '',
text,
}));
}
/**
* Passages from this agent's own knowledge that bear on the question.
*
* Returns `{ passages, source, available, note }`. `available: false` means
* there is nothing to search — no agent, no entries, or an agent that does not
* cover this page — and the note says which. A caller must render that rather
* than treating an empty result as "the documents say nothing".
*
* @param {Object} options
* @param {Object|null} options.agent The active agent.
* @param {string|null} options.contextId The page's assistant context.
* @param {string} options.question What was asked.
* @param {number} options.limit Most passages to return.
*/
/** @param {any} [options] */
export function retrieveKnowledge({
agent = null, contextId = null, question = '', limit = 3,
} = {}) {
const empty = (note) => ({
passages: [], source: 'declared', available: false, note,
});
if (!agent) return empty('No agent is active, so there is no knowledge to search.');
/* The page boundary. An agent constrained here contributes no knowledge, for
the same reason it contributes no skills. */
if (contextId && !agentCovers(agent, contextId)) {
return empty(`${agent.name} does not cover this page, so its knowledge is not in reach here.`);
}
const entries = (agent.knowledge || []).filter((entry) => entry.body || entry.url);
if (!entries.length) {
return empty(`${agent.name} has no knowledge attached.`);
}
const wanted = terms(question);
if (!wanted.length) {
return {
passages: [], source: 'declared', available: true,
note: 'Ask about something specific and I will check this agent\'s knowledge.',
};
}
const passages = entries
.flatMap(chunk)
.map((passage) => {
const words = new Set(terms(`${passage.label} ${passage.text}`));
const hits = wanted.filter((word) => words.has(word));
return { ...passage, score: hits.length, matched: hits };
})
/* A passage that matches nothing is not a weak answer, it is a different
subject. Returning it would put unrelated prose under a question and
let the reader assume it was relevant. */
.filter((passage) => passage.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit);
return {
passages,
source: 'declared',
available: true,
note: passages.length
? null
: `Nothing in ${agent.name}'s knowledge covers that.`,
};
}
/**
* Whether a question can be answered from knowledge at all.
*
* Used to decide whether to say "no source is configured" or "the source has
* nothing on this" — two different answers, and reporting the second when the
* first is true would imply a corpus exists.
*/
export const hasKnowledge = (agent) =>
Boolean(agent && (agent.knowledge || []).some((entry) => entry.body || entry.url));
/** The documents an agent carries, for a configuration screen. */
export const knowledgeDocuments = (agent) =>
(agent?.knowledge || []).map((entry) => ({
id: entry.id,
label: entry.label,
kind: entry.kind,
url: entry.url || '',
chunks: chunk(entry).length,
}));

View File

@@ -1,276 +0,0 @@
import {
allSkills, normalizeDefinition, parseFrontmatter, sectionBullets, sectionSource, sectionText,
} from '@/lib/skills/registry';
import { slugify } from '@/lib/skills/uiConfig';
import { canonicalPage } from '@/lib/skills/surfaces';
import { normalizeAgent } from './agentConfig';
/**
* The agent registry.
*
* An agent is a Markdown file, for the same reason a skill is: frontmatter
* declares what it is and what it carries, the body says how it should behave.
* Nothing here executes Markdown — an agent names skill ids, and the skill
* registry decides what those ids mean.
*
* **This is not a second parser.** `parseFrontmatter`, `normalizeDefinition`
* and the three section readers are imported from the skill registry, so an
* agent file and a skill file are read by exactly the same code and cannot
* drift into two formats. The BOM, CRLF, blank-line and trailing-space
* tolerance a skill definition gets is the tolerance an agent definition gets,
* because it is the same function.
*
* Registration is the filesystem, as it is for skills: `import.meta.glob`
* picks up everything under `src/agents/`, so a new agent is registered by
* existing.
*
* What an agent may *do* with what it carries is `runtime.js`'s decision, and
* the boundary it cannot cross is the page's — an agent contributes to the
* disabled list and nothing else, so it can only ever narrow a page.
*/
/* `import.meta.glob` is a Vite build-time API. `tsc` models only the standard
`ImportMeta`, so it reports this as a missing property — a false positive
rather than a defect. Suppressed rather than cast: a cast would assert a type
nothing here can verify, and this file is the one place the glob appears. */
// @ts-ignore -- Vite build-time API, absent from the standard ImportMeta type
const FILES = import.meta.glob('/src/agents/**/*.md', { query: '?raw', import: 'default', eager: true });
/**
* One Markdown definition → one agent.
*
* Shared by the files on disk and by anything authored at runtime, so an agent
* written in the editor is parsed by the same code as a shipped one.
*/
export function parseAgent(raw, { path = 'custom', custom = false } = {}) {
const { data, body } = parseFrontmatter(raw);
/**
* The definition's id.
*
* `id:` when written, and it always wins — an id is the address skills,
* subagents and stored preferences refer to, and deriving over the top of
* one would silently rename an agent. Falling back to a slug of the name
* matches what an author means by leaving it out; falling back to the
* filename is right only for a file, which is why it is last.
*/
const id = data.id || slugify(data.name) || path.split('/').pop().replace(/\.md$/, '');
const { agent, errors } = normalizeAgent(data, {
agentId: id,
/* Instructions are the body's own section, not a frontmatter string: they
are prose, and prose belongs under a heading where it can be written
and read as prose. */
body: sectionSource(body, 'Instructions') || '',
});
return {
id,
name: data.name || 'Untitled agent',
description: data.description || '',
...agent,
/* What this definition lost on the way in. Carried on the record rather
than thrown, so one bad entry costs its author that entry and a message
instead of the whole file — and so `readAgentRegistry` can report it for
shipped definitions too, which are otherwise never re-checked. */
errors,
/* What this agent is for, as bullets, for the switcher and the detail
page. Read with the same section reader skills use. */
purpose: sectionBullets(body, 'Purpose'),
summary: sectionText(body, 'Purpose'),
/* The definition as written. The editor edits this; everything else reads
the parsed form, so there is one artefact behind all of them. */
markdown: raw,
source: custom ? 'account' : 'repository',
path,
body,
custom,
};
}
/** Every agent on disk, parsed once at module load. */
export const AGENTS = Object.entries(FILES)
.map(([path, raw]) => parseAgent(raw, { path }))
.sort((a, b) => a.name.localeCompare(b.name));
/**
* The registry as actually assembled, with everything that went wrong
* assembling it.
*
* The same four failure modes the skill registry reports, because they are the
* same four failures and all of them are silent:
*
* - `unreadable` — a stored definition that will not parse. Dropped without
* a word, an agent simply stops existing and the list looks healthy.
* - `incomplete` — a definition that parses but lost a field on the way in.
* - `shadowed` — an account definition replacing a shipped one of the same
* id. Intended, and indistinguishable from the shipped one having broken.
* - `unattached` — an agent naming a skill id no registered skill carries.
* This is the one that matters most here: a skill renamed or removed
* leaves every agent that named it quietly carrying one capability fewer.
*/
export function readAgentRegistry(customSources = [], { customSkills = [] } = {}) {
const diagnostics = [];
const custom = [];
customSources.forEach((entry, i) => {
const path = entry?.path || `custom/${i}.md`;
let agent = null;
try {
agent = parseAgent(entry?.raw ?? entry, { path, custom: true });
} catch (error) {
diagnostics.push({
level: 'error',
kind: 'unreadable',
path,
agentId: null,
message: `A stored agent could not be read and is not registered. ${error?.message || ''}`.trim(),
});
return;
}
if (!agent?.id) {
diagnostics.push({
level: 'error',
kind: 'unreadable',
path,
agentId: null,
message: 'A stored agent has no `id` and is not registered.',
});
return;
}
custom.push(agent);
});
const byId = new Map(AGENTS.map((a) => [a.id, a]));
for (const agent of custom) {
if (byId.has(agent.id) && !byId.get(agent.id).custom) {
diagnostics.push({
level: 'warning',
kind: 'shadowed',
path: agent.path,
agentId: agent.id,
message: `\`${agent.id}\` replaces the built-in agent of the same id. The built-in definition is not registered.`,
});
}
byId.set(agent.id, agent);
}
const agents = [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
/* A definition that lost part of itself on the way in. Reported for every
agent, not only stored ones, so a file on disk that stops resolving after
a vocabulary change is just as visible. */
for (const agent of agents) {
for (const message of agent.errors || []) {
diagnostics.push({
level: 'error', kind: 'incomplete', path: agent.path, agentId: agent.id, message,
});
}
}
/* Skills and subagents that do not resolve. Both are addresses, and an
address that points at nothing is the failure this catches. */
const skillIds = new Set(allSkills(customSkills).map((s) => s.id));
const agentIds = new Set(agents.map((a) => a.id));
for (const agent of agents) {
for (const skillId of agent.skills) {
if (skillIds.has(skillId)) continue;
diagnostics.push({
level: 'error',
kind: 'unattached',
path: agent.path,
agentId: agent.id,
message: `\`${agent.id}\` names the skill \`${skillId}\`, which no registered skill provides.`,
});
}
for (const subId of agent.subagents) {
if (agentIds.has(subId)) continue;
diagnostics.push({
level: 'error',
kind: 'unattached',
path: agent.path,
agentId: agent.id,
message: `\`${agent.id}\` names the subagent \`${subId}\`, which no registered agent provides.`,
});
}
}
return { agents, diagnostics };
}
/** Every agent, shipped and stored. */
export function allAgents(customSources = [], options = {}) {
return readAgentRegistry(customSources, options).agents;
}
/** Everything that went wrong assembling it, for the management page. */
export function agentDiagnostics(customSources = [], options = {}) {
return readAgentRegistry(customSources, options).diagnostics;
}
/** One agent by id, or null. */
export const getAgent = (agents, id) =>
(agents || []).find((a) => a.id === id) || null;
/** The agents an author may actually pick: published, never archived. */
export const publishedAgents = (agents = []) =>
agents.filter((a) => a.status === 'published');
/**
* Agents matching a search, over the fields a person would search by.
*
* An empty query is every agent rather than none — the switcher opens with the
* box empty, and an empty list would read as "there are no agents".
*/
export function searchAgents(agents = [], query = '') {
const q = String(query || '').trim().toLowerCase();
if (!q) return agents;
return agents.filter((a) =>
[a.name, a.description, a.trigger, ...(a.purpose || [])]
.filter(Boolean)
.some((field) => String(field).toLowerCase().includes(q))
);
}
/**
* Validates a definition before it is stored. Returns an error string or null.
*
* The order is the order an author would fix things in, which is what
* `validateSkillSource` does and why this reads the same way.
*
* Deliberately *not* refused: an agent carrying no skills. Five of this
* product's pages have no Owliver skills at all and answer from their own page
* responder, so refusing a skill-less agent would mean inventing placeholder
* skills to make those pages configurable. See the note in `agentConfig.js`.
*/
export function validateAgentSource(raw) {
if (!String(raw ?? '').trim()) return 'Paste or upload a Markdown definition.';
let agent;
try {
agent = parseAgent(raw, { custom: true });
} catch (error) {
/* The YAML subset reports the line it failed on, which is far more useful
than "could not be parsed". */
return `That definition could not be parsed. ${error?.message || ''}`.trim();
}
if (!agent.id) return 'The frontmatter needs an `id`.';
if (!/^[a-z0-9][a-z0-9-]*$/.test(agent.id)) {
return 'The `id` must be lower-case letters, numbers and dashes.';
}
if (!raw.includes('name:') || agent.name === 'Untitled agent') {
return 'The frontmatter needs a `name`.';
}
if (!agent.pages.length) {
return 'An agent needs at least one `pages:` entry, or it can never be offered anywhere.';
}
if (agent.errors?.length) return agent.errors[0];
return null;
}
/** Page keys an agent may cover, for the editor's own guidance. */
export const agentPageKeys = (pages = []) =>
pages.map((p) => canonicalPage(p) || p).filter(Boolean);

View File

@@ -1,388 +0,0 @@
import { canonicalPage } from '@/lib/skills/surfaces';
import { pageKeyForContext } from '@/lib/skills/registry';
import { getAgent } from './registry';
import { reasoningFor } from './vocabulary';
/**
* The agent runtime.
*
* Its whole job is to *narrow*. The page decides what is in reach; an agent
* decides how much of that reach to use, and can never extend it.
*
* Current PageContext
* ↓
* Agent ← this file
* ↓
* Agent Skills
* ↓
* Allowed Data / Knowledge / Tools
* ↓
* Owliver
*
* The narrowing is arithmetic rather than policy. `getSkillsForPage` filters on
* the page *and* on a list of disabled ids in one expression
* (`skills/registry.js`), so an agent participates only by adding ids to that
* list. There is no code path by which adding an id can make a skill appear —
* which is why "an agent cannot widen a page" is a property of the data flow
* and not a rule someone has to remember to enforce.
*
* Everything downstream — knowledge, tools, the provider request — is derived
* from the scoped skill list rather than from the agent directly, so each of
* them inherits the same boundary without restating it.
*
* The stages below are named and individually callable. Today they are called
* in order by the existing panel; a future orchestrator can drive them in a
* different order without any of them changing, which is the whole reason they
* are separate functions rather than one.
*/
/* ── Scope ──────────────────────────────────────────────────────────────── */
/**
* The skill ids an agent carries, including one level of subagent.
*
* One level, with a visited set. A deeper walk would let a chain of agents
* assemble a skill list nobody wrote down, and the cycle guard is not
* optional — `readAgentRegistry` rejects self-reference, but A→B→A is only
* caught here.
*
* A subagent that is not published contributes nothing: an archived or draft
* agent has been taken out of service, and inheriting its skills through a
* parent would put it back.
*/
export function agentSkillIds(agent, agents = []) {
if (!agent) return [];
const ids = new Set(agent.skills || []);
const seen = new Set([agent.id]);
for (const subId of agent.subagents || []) {
if (seen.has(subId)) continue;
seen.add(subId);
const sub = getAgent(agents, subId);
if (!sub || sub.status !== 'published') continue;
for (const id of sub.skills || []) ids.add(id);
}
return [...ids];
}
/**
* The disabled list an agent implies: everything it does not carry.
*
* **This is the entire mechanism.** Callers pass the result wherever
* `disabledSkills` already goes — `skillsForContext`, `matchSkill`,
* `owliverSuggestions`, `resolveIntent` — and the page filter does the rest.
*
* With no agent the input is returned unchanged, so "no agent selected" is
* byte-for-byte the behaviour the product had before any of this existed. That
* is asserted in `skill-check.mjs` rather than assumed.
*/
export function agentScopedDisabled(agent, skills = [], disabled = []) {
if (!agent) return disabled;
const carried = new Set(agentSkillIds(agent, []));
const withheld = skills
.map((s) => (typeof s === 'string' ? s : s.id))
.filter((id) => id && !carried.has(id));
return [...new Set([...disabled, ...withheld])];
}
/** The same, with subagents resolved against the full registry. */
export function agentScopedDisabledWith(agent, agents, skills = [], disabled = []) {
if (!agent) return disabled;
const carried = new Set(agentSkillIds(agent, agents));
const withheld = skills
.map((s) => (typeof s === 'string' ? s : s.id))
.filter((id) => id && !carried.has(id));
return [...new Set([...disabled, ...withheld])];
}
/* ── Coverage ───────────────────────────────────────────────────────────── */
/** Does this agent cover the page behind this assistant context? */
export function agentCovers(agent, contextId) {
if (!agent || !contextId) return false;
const pageKey = pageKeyForContext(contextId);
if (!pageKey) return false;
const wanted = canonicalPage(pageKey) || pageKey;
return (agent.pages || []).some((p) => (canonicalPage(p) || p) === wanted);
}
/** Published agents covering this page, most specific first. */
export function agentsForContext(agents = [], contextId) {
return agents
.filter((a) => a.status === 'published' && agentCovers(a, contextId))
/* Fewest pages first: a page's own agent is more specific than the root,
and specificity is what makes it the sensible default. */
.sort((a, b) => a.pages.length - b.pages.length || a.name.localeCompare(b.name));
}
/**
* The general agent every page falls back to.
*
* Named once, here, because two different things need it and neither should
* carry its own copy: resolving a default, and deciding whether a page has an
* agent *of its own*.
*/
export const FALLBACK_AGENT_ID = 'krow-workforce-agent';
/**
* The agent written *for* this page, if there is one.
*
* The general agent is deliberately excluded. It covers every surface — which
* is what makes it a fallback — so counting it as a page's own agent would make
* "does this page have a native agent?" true everywhere and the distinction
* meaningless.
*
* Returns null on a page nobody wrote an agent for. That is a normal state, not
* a broken one: see `resolveDefaultAgent`.
*/
export function nativeAgentForContext(agents = [], contextId) {
return agentsForContext(agents, contextId).find((a) => a.id !== FALLBACK_AGENT_ID) || null;
}
/**
* The general agent, when it can answer here.
*
* Falls through to whichever published agent covers the page if the general one
* has been archived or does not list this surface — a page must never be left
* without an agent because of how the registry happens to be configured.
*/
export function fallbackAgentForContext(agents = [], contextId) {
const general = getAgent(agents, FALLBACK_AGENT_ID);
if (general && general.status === 'published' && agentCovers(general, contextId)) return general;
return agentsForContext(agents, contextId)[0] || null;
}
/**
* The agent a page opens with when nobody has chosen one.
*
* Two modes, and the second is the one that was missing:
*
* 1. **The page has an agent of its own** — Positions, Analytics, Activity and
* the five others. That agent answers, because its instructions and skills
* were written for this page.
* 2. **The page has none** — Settings, the workspace surfaces, Agent
* Configure. The *general* agent answers.
*
* "No native agent" is not "no Owliver". A page without a specialist is a page
* the general agent handles, exactly as Owliver handled every page before
* specialists existed. Nothing here can leave a page agent-less, and
* `skill-check` asserts the general agent covers every surface a skill may name,
* so mode 2 always has something to resolve to.
*/
export function resolveDefaultAgent(agents = [], contextId) {
return nativeAgentForContext(agents, contextId) || fallbackAgentForContext(agents, contextId);
}
/**
* The agent a page opens with. Kept as the name every existing caller uses.
*
* Behaviourally identical to what it did before — on a page with its own agent
* that agent is both "first by specificity" and "the native one" — but it now
* says *why* it returns what it returns.
*/
export function defaultAgentForContext(agents = [], contextId) {
return resolveDefaultAgent(agents, contextId);
}
/* ── Selection ──────────────────────────────────────────────────────────── */
/**
* Whether a chosen agent still applies where the reader is now.
*
* A selection is made *somewhere*. Carrying only its id meant a choice made on
* one page followed the reader onto every other one, so choosing the Positions
* Agent on Positions and then opening Settings left Settings constrained by an
* agent nobody had chosen for it — the page looked broken, and the reason was
* invisible.
*
* So a selection carries the context it was made on, and three cases fall out:
*
* - **It covers this page.** It applies. This is a selection working as
* intended, and it survives navigation across every page it covers.
* - **It does not cover this page, but this is where it was chosen.** It
* applies, constrained — the reader picked a specialist here on purpose and
* is owed the honest "this agent does not cover this page" rather than a
* silent swap.
* - **It does not cover this page and was chosen elsewhere.** It is stale.
* It is retired, and the page resolves its own default.
*
* `retire` rather than "ignore for now": a constrained choice that the reader
* has navigated away from is spent. Keeping it would mean returning to that page
* later and finding it constrained by a decision made in a different session of
* attention.
*
* Pure, and takes the selection as a value, so the whole rule is testable
* without a browser, a router or a React tree.
*/
export function resolveSelection(agents = [], selection = null, contextId = null) {
/* A bare id is accepted so an account-level default — which was never chosen
on any page — can be resolved by the same rule. */
const id = typeof selection === 'string' ? selection : selection?.id || null;
const chosenOn = typeof selection === 'string' ? null : selection?.contextId || null;
if (!id) return { id: null, covers: false, retire: false };
const agent = getAgent(agents, id);
/* An agent that no longer exists — deleted, or a stored id from an older
registry. Nothing to apply and nothing worth keeping. */
if (!agent) return { id: null, covers: false, retire: true };
if (agentCovers(agent, contextId)) return { id, covers: true, retire: false };
if (chosenOn && chosenOn === contextId) return { id, covers: false, retire: false };
return { id: null, covers: false, retire: true };
}
/**
* Which agent will actually answer, and why.
*
* Returns the requested agent even when it does not cover the page, together
* with `covers: false` and the page's native agent as `suggestion`. Silently
* swapping in a different agent would be worse than the honest answer: the
* reader chose one, and a panel that quietly answers as another is lying about
* which it is.
*/
export function resolveAgentForTurn(agents = [], activeId, contextId) {
const requested = activeId ? getAgent(agents, activeId) : null;
const native = defaultAgentForContext(agents, contextId);
if (!requested) return { agent: native, covers: Boolean(native), requested: null, suggestion: null };
const covers = agentCovers(requested, contextId);
return {
agent: requested,
covers,
requested,
suggestion: covers ? null : native,
};
}
/* ── Starters ───────────────────────────────────────────────────────────── */
/**
* The chips this agent offers, in the shape the existing `PromptChips` reads.
*
* An agent that does not cover the page offers none: a starter is a promise
* that the question will be answered here, and it would not be.
*/
export function agentStarters(agent, contextId = null) {
if (!agent) return [];
if (contextId && !agentCovers(agent, contextId)) return [];
return (agent.starters || []).map((starter) => ({
label: starter.label,
prompt: starter.prompt || starter.label,
/* No capability: a starter is a question, and which skill answers it is
decided by the same matcher that handles anything typed. Naming one here
would let an agent address a skill the page has not offered. */
capability: null,
source: 'agent',
}));
}
/* ── Question classification ────────────────────────────────────────────── */
/**
* Words that ask what a document says rather than what the records show.
*
* Deliberately narrow. Misreading a structured question as a knowledge one
* costs the reader a real answer and replaces it with a policy quotation, which
* is a worse failure than the reverse — so anything ambiguous stays structured.
*/
const KNOWLEDGE_TERMS = [
'policy', 'policies', 'procedure', 'guideline', 'guidelines', 'handbook',
'rule', 'rules', 'documentation', 'what does it say', 'according to',
'are we allowed', 'am i allowed', 'supposed to',
];
/** Words that ask for a figure out of the records. */
const STRUCTURED_TERMS = [
'how many', 'how much', 'count', 'total', 'average', 'rate', 'trend',
'compare', 'list', 'show me', 'who', 'which', 'when', 'breakdown', 'summary',
'exceeded', 'more than', 'less than', 'over', 'under',
];
/**
* Whole-word matching, not substring.
*
* `includes` is wrong here and wrong in a way that is hard to see: "overtime"
* contains "over", so "what does our overtime policy say?" matched a
* comparison term and was classified as needing records. A question about a
* document would have been answered with a table.
*
* Word boundaries on both ends, so a phrase still matches inside a sentence but
* a term never matches inside a longer word.
*/
const hasAny = (text, terms) => terms.some((term) => {
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`\\b${escaped}\\b`).test(text);
});
/**
* Which sources a question needs.
*
* Three answers, and the distinction matters because they read different
* things:
*
* - `structured` — "which employees worked more than 20 overtime hours" is a
* query over records. It goes to the data resolvers. **Never** to retrieval:
* Krow's operational records are not embedded, and answering this from
* prose would produce a confident number nobody can trace.
* - `knowledge` — "what does our overtime policy say" is a question about a
* document.
* - `combined` — "which employees exceeded the overtime policy this month"
* needs both, and the runtime composes them.
*
* Keyword matching, like every other matcher in this product: the answers are
* computed locally and deterministically, so the routing has to be inspectable
* in the same way.
*/
export function classifyQuestion({ question = '' } = {}) {
const text = String(question).toLowerCase();
const knowledge = hasAny(text, KNOWLEDGE_TERMS);
const structured = hasAny(text, STRUCTURED_TERMS);
if (knowledge && structured) return 'combined';
if (knowledge) return 'knowledge';
return 'structured';
}
/* ── Reasoning ──────────────────────────────────────────────────────────── */
/**
* How much work this turn is worth, as a number.
*
* Read off the agent's declared mode so the runtime never branches on a mode
* name. `balanced` is the default and is deliberately today's behaviour, so an
* agent that says nothing about reasoning answers exactly as the panel does now.
*/
export function reasoningDepth(agent) {
return reasoningFor(agent?.reasoning)?.depth ?? 2;
}
/**
* What the runtime tells the provider about the agent.
*
* Deliberately small and serializable: an id, the instructions, the mode. Not
* the skill list, and not the records — the provider is handed what the agent
* *is*, and the data it may read has already been decided by the page.
*/
export function agentRequest(agent, contextId = null) {
if (!agent) return null;
return {
id: agent.id,
name: agent.name,
instructions: agent.instructions || '',
trigger: agent.trigger || '',
reasoning: agent.reasoning,
depth: reasoningDepth(agent),
webSearch: Boolean(agent.webSearch),
covers: contextId ? agentCovers(agent, contextId) : true,
};
}

View File

@@ -1,137 +0,0 @@
import { useCallback, useMemo } from 'react';
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
import { reportSave } from '@/lib/skills/saveFeedback';
import { AGENTS, parseAgent, readAgentRegistry, validateAgentSource } from './registry';
import { customAgentSource, removeCustomAgent, upsertCustomAgent } from './customAgents';
import { archiveAgent, duplicateAgent, publishAgent, restoreAgent } from './agentLifecycle';
/**
* Reading and writing agents, in one place.
*
* Every screen that changes an agent goes through here. That is deliberate: the
* list can publish, the detail page can publish, and two implementations of
* "publish" would eventually disagree about what publishing means.
*
* Storage is the account's preferences, holding **Markdown** — the same
* artefact a file in `src/agents/` is. The forms never expose it: a person
* fills in fields, `agentPatch` writes them into frontmatter, and the parser
* reads them back. Markdown stays the definition format without ever being
* something an HR user has to see.
*
* Editing a shipped agent writes an account definition of the same id, which
* the registry reports as `shadowed`. That is the existing override mechanism,
* not a new one — a shipped agent is never mutated on disk.
*/
export function useAgents() {
const preferences = usePreferences();
const updatePreferences = useUpdatePreferences();
const stored = useMemo(() => preferences.customAgents || [], [preferences.customAgents]);
const customSkills = useMemo(() => preferences.customSkills || [], [preferences.customSkills]);
const { agents, diagnostics } = useMemo(
() => readAgentRegistry(stored, { customSkills }),
[stored, customSkills]
);
/** Whether this id is shipped with the product rather than authored here. */
const isShipped = useCallback((id) => AGENTS.some((a) => a.id === id), []);
/** Whether the account has its own definition for this id. */
const isOverridden = useCallback((id) => customAgentSource(stored, id) !== null, [stored]);
/**
* The Markdown behind an agent.
*
* The account's own copy when there is one, otherwise the shipped definition —
* so editing a shipped agent starts from what it actually says rather than
* from a blank.
*/
const sourceFor = useCallback(
(id) => customAgentSource(stored, id) || AGENTS.find((a) => a.id === id)?.markdown || null,
[stored]
);
/**
* Writes a definition.
*
* Validated first and refused with a message rather than stored broken: a
* definition that cannot be read is an agent that silently stops existing.
* Returns `{ ok, error }` so a form can stay on screen and say why.
*/
const save = useCallback(async (/** @type {string} */ source, /** @type {any} */ { message } = {}) => {
const problem = validateAgentSource(source);
if (problem) return { ok: false, error: problem };
const { agent, next } = upsertCustomAgent(stored, source);
await updatePreferences.mutateAsync(
{ customAgents: next },
reportSave(message || `${agent.name} saved`)
);
return { ok: true, agent };
}, [stored, updatePreferences]);
/** Removes the account's definition. A shipped agent returns to its shipped form. */
const remove = useCallback(async (id) => {
const next = removeCustomAgent(stored, id);
await updatePreferences.mutateAsync(
{ customAgents: next },
reportSave(isShipped(id) ? 'Reverted to the shipped definition' : 'Agent removed')
);
return { ok: true };
}, [stored, updatePreferences, isShipped]);
/**
* Publishes, refusing to overwrite a newer published version.
*
* Returns `{ ok: false, conflict }` when the stored definition has moved on,
* so the screen can say what would be lost instead of losing it.
*/
const publish = useCallback(async (id) => {
const source = sourceFor(id);
if (!source) return { ok: false, error: 'That agent has no definition to publish.' };
const live = agents.find((a) => a.id === id);
const result = publishAgent(source, {
publishedVersion: live?.status === 'published' ? live.version : 0,
});
if (result.conflict) return { ok: false, conflict: result.conflict };
return save(result.source, { message: `${live?.name || id} published` });
}, [sourceFor, agents, save]);
const archive = useCallback(async (id) => {
const source = sourceFor(id);
if (!source) return { ok: false, error: 'That agent has no definition to archive.' };
return save(archiveAgent(source), { message: `${agents.find((a) => a.id === id)?.name || id} archived` });
}, [sourceFor, agents, save]);
const restore = useCallback(async (id) => {
const source = sourceFor(id);
if (!source) return { ok: false, error: 'That agent has no definition to restore.' };
return save(restoreAgent(source), { message: 'Restored as a draft' });
}, [sourceFor, save]);
const duplicate = useCallback(async (id) => {
const source = sourceFor(id);
if (!source) return { ok: false, error: 'That agent has no definition to copy.' };
const copy = duplicateAgent(source, { existingIds: agents.map((a) => a.id) });
const result = await save(copy, { message: 'Copy created as a draft' });
return result.ok ? { ...result, id: parseAgent(copy, { custom: true }).id } : result;
}, [sourceFor, agents, save]);
return {
agents,
diagnostics,
saving: updatePreferences.isPending,
isShipped,
isOverridden,
sourceFor,
save,
remove,
publish,
archive,
restore,
duplicate,
};
}

View File

@@ -1,117 +0,0 @@
/**
* The closed vocabulary an agent definition is allowed to use.
*
* This file is to agents what `surfaces.js` is to skills: a hand-written table
* of every value a definition may name, and nothing else. An agent definition
* is configuration, and the way it stays configuration is that nothing here is
* looked up dynamically, evaluated, or turned into a component — a definition
* names a key, and this file answers whether that key exists.
*
* Icons are ids only. The id → component table lives beside the components
* that draw them, for the same reason `SECTION_COMPONENTS` does: a definition
* must never be able to reach a component nobody wrote down.
*/
/* ── Lifecycle ──────────────────────────────────────────────────────────── */
/**
* Where an agent is in its life.
*
* `draft` is the default for anything authored, so creating an agent never
* publishes one. Only `published` agents are offered in the switcher or
* resolved as a page's native agent — an archived agent keeps its definition
* and stops answering.
*/
export const AGENT_STATUSES = ['draft', 'published', 'archived'];
export const DEFAULT_AGENT_STATUS = 'draft';
/* ── Reasoning ──────────────────────────────────────────────────────────── */
/**
* How much work an answer is worth.
*
* `depth` is the only thing the runtime reads, so a mode is a number with a
* name rather than a branch: adding one is a row here, not a condition
* somewhere else. `balanced` is the default and is deliberately today's
* behaviour, so an agent that says nothing about reasoning answers exactly as
* the panel does now.
*/
export const REASONING_MODES = [
{
id: 'fast',
label: 'Fast',
depth: 1,
summary: 'Use for simple questions and quick summaries.',
},
{
id: 'balanced',
label: 'Balanced',
depth: 2,
summary: 'Default mode for normal workforce analysis.',
},
{
id: 'deep',
label: 'Deep',
depth: 3,
summary: 'Use for complex multi-source analysis.',
},
];
export const SUPPORTED_REASONING = REASONING_MODES.map((m) => m.id);
export const DEFAULT_REASONING = 'balanced';
export const reasoningFor = (id) =>
REASONING_MODES.find((m) => m.id === String(id || '').trim()) || null;
export const reasoningLabel = (id) => reasoningFor(id)?.label || id;
/* ── Knowledge ──────────────────────────────────────────────────────────── */
/**
* The kinds of thing an agent can be told, as distinct from what it can *do*.
*
* Knowledge is reference material an author wrote down; a skill is a capability
* that reads live records. Keeping the two vocabularies apart is what stops a
* knowledge entry being mistaken for a computed figure — see `knowledge.js`.
*/
export const KNOWLEDGE_KINDS = ['note', 'link', 'skill-reference'];
export const DEFAULT_KNOWLEDGE_KIND = 'note';
/* ── Permissions ────────────────────────────────────────────────────────── */
/** Who may reach an agent. */
export const AGENT_ACCESS = ['all', 'specific'];
export const DEFAULT_AGENT_ACCESS = 'all';
/** What a named person may do with it. */
export const PERMISSION_ROLES = ['manager', 'editor', 'viewer'];
export const DEFAULT_PERMISSION_ROLE = 'viewer';
/* ── Icons ──────────────────────────────────────────────────────────────── */
/**
* The icons an agent may name.
*
* Ids, not components. Deliberately small — an agent is identified by its name
* first, and a long list only makes two agents easier to confuse.
*/
export const AGENT_ICONS = [
'owliver',
'sparkles',
'briefcase',
'users',
'user-check',
'layers',
'graduation-cap',
'bar-chart',
'activity',
'shield',
];
export const DEFAULT_AGENT_ICON = 'owliver';
export const isAgentIcon = (id) => AGENT_ICONS.includes(String(id || '').trim());

View File

@@ -0,0 +1,203 @@
import { createTenantCustomer } from '@/api/doormile/endpoints';
// ==============================|| Doormile AI — write actions ||============================== //
//
// The FIRST write capability in the assistant. Read the rules before adding
// another one.
//
// The contract (assistant/CLAUDE.md §4): an intent never mutates. It returns a
// PROPOSAL — the exact payload it would submit — and nothing reaches the API
// until the operator presses Create in the panel. Parsing, validation and
// execution are separated here so the proposal can be built, shown and
// discarded without any possibility of a request going out.
//
// Customer creation was chosen as the first write deliberately: it needs two
// required fields where an order needs fourteen, it has no CityGate pincode
// gate, no geocoding and no delivery slot, and a wrong record is an edit
// rather than a rider dispatched to the wrong address.
// "create a customer", "add new client" — an explicit verb + noun. Deliberately
// narrow: nothing here should fire on a question that merely mentions customers.
export const CREATE_CUSTOMER_TRIGGER = /\b(?:create|add|register|new)\s+(?:a\s+|an\s+|the\s+)?(?:new\s+)?(?:customer|client)\b/i;
// Words that are part of the instruction rather than the person's name.
const FILLER =
/\b(?:create|add|register|new|a|an|the|customer|client|named|called|with|phone|number|mobile|contact|no|email|id|please)\b/gi;
// Pulls what it can out of free text. Anything it can't find stays undefined
// and is asked for — never guessed.
export const parseCustomerDraft = (text) => {
const raw = String(text || '');
const email = (raw.match(/[\w.+-]+@[\w-]+\.[\w.]{2,}/) || [])[0];
// Exactly ten digits, standalone. A longer run is not a phone number and
// must not be silently truncated into one.
const phone = (raw.match(/(?<!\d)(\d{10})(?!\d)/) || [])[1];
// Whatever is left after removing the instruction, the phone and the email
// is the person's name.
let nameArea = raw;
if (email) nameArea = nameArea.replace(email, ' ');
if (phone) nameArea = nameArea.replace(phone, ' ');
const nameWords = nameArea
.replace(FILLER, ' ')
.replace(/[^A-Za-z .'-]/g, ' ')
.split(/\s+/)
.filter((w) => w.length > 1);
return {
firstname: nameWords[0],
lastname: nameWords.slice(1).join(' ') || undefined,
phone,
email
};
};
// Mirrors createCustomer.js's own checks — a name, and a phone of exactly ten
// digits. If the form would refuse it, the assistant refuses it too, rather
// than letting the server decide.
//
// No tenant check: a customer record carries no tenantid, and the documented
// POST body doesn't take one. The page's own `isStaffLogin && !tid` guard
// exists because its dropdown sends a speculative tenantid; the assistant
// doesn't send one at all.
export const validateCustomerDraft = (draft) => {
const missing = [];
if (!draft.firstname) missing.push('the customer’s name');
if (!draft.phone || !/^\d{10}$/.test(String(draft.phone))) missing.push('a 10-digit mobile number');
return { ok: missing.length === 0, missing };
};
// The exact body that will be POSTed.
//
// Documented body for POST /admin/tenantcustomers is
// { firstname, lastname, phone, email } — and a customer record carries NO
// tenantid, which is why the tenant field was removed. A real
// GET /admin/customers response does carry address, doorno, landmark, suburb,
// city, state, postcode, latitude and longitude, so those are sent on a
// best-effort basis: empty strings are dropped rather than sent as noise, and
// if the server ignores the rest nothing breaks.
const clean = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined && v !== null && v !== ''));
export const buildCustomerPayload = (draft) =>
clean({
firstname: draft.firstname,
lastname: draft.lastname || '',
phone: draft.phone,
email: draft.email || '',
address: draft.address,
doorno: draft.doorno,
landmark: draft.landmark,
suburb: draft.suburb,
city: draft.city,
state: draft.state,
postcode: draft.postcode,
latitude: draft.latitude,
longitude: draft.longitude
});
// The ONLY function in the assistant that mutates anything. Called exclusively
// from the panel's confirm handler — never from an intent's run().
//
// ---- Which endpoint, and why -----------------------------------------------
//
// Writes to POST /admin/tenantcustomers. This is now settled by evidence, not
// by reading the docs:
//
// POST /admin/customers → 405 Method Not Allowed (confirmed live)
//
// 405 is the unambiguous answer: the route exists, and POST is not among its
// methods. express-console-api.md lists /admin/customers as GET + PATCH only,
// and the server agrees. It was pointed there briefly on explicit instruction;
// the live 405 settled it.
//
// The consequence, which the assistant states in its success message rather
// than leaving the operator to discover: a customer created here does NOT
// appear on the Customers page, because that page reads GET /admin/customers.
// On that resource a customer comes into existence as a side effect of a
// booking — POST /admin/expressbooking documents `customer_phone` as "creates a
// Guest customer if unknown". A B2C customer is, by design, someone who has
// ordered.
//
// To make created customers visible on that page, one of these has to happen:
// • the Customers page reads /admin/tenantcustomers (tried once, reverted —
// it changes what that page means, and its edit dialog would then PATCH a
// different store by id), or
// • the backend adds POST /admin/customers.
//
// The payload keeps the address fields. The documented body is
// { firstname, lastname, phone, email }; the rest are sent best-effort and
// ignored if unsupported.
export const executeCreateCustomer = async (payload) => {
const started = Date.now();
const call = {
name: 'createTenantCustomer',
target: 'POST /admin/tenantcustomers',
stats: Object.keys(payload).join(', ')
};
try {
const res = await createTenantCustomer(payload);
const duration = `${Date.now() - started}ms`;
// doormileApi mutations return the full envelope, so a `success: false`
// arrives as a resolved promise, not a rejection.
if (res && res.success === false) {
return {
ok: false,
message: res.message || 'The server rejected the customer.',
sourceCalls: [{ ...call, duration, status: 'error', errorMessage: res.message || 'Rejected' }]
};
}
const created = res?.data || res;
return {
ok: true,
id: created?.appcustomerid ?? created?.customerid ?? created?.id,
created,
message: 'Customer created.',
sourceCalls: [{ ...call, duration, status: 'complete', stats: `created id ${created?.appcustomerid ?? created?.id ?? '—'}` }]
};
} catch (err) {
// An HTTP failure used to throw straight past this function, and the panel
// printed a generic "could not be created" with the status thrown away —
// which is the one detail needed to tell "the route does not exist" apart
// from "the body was wrong". Report the status, the server's own message,
// and name the endpoint.
const duration = `${Date.now() - started}ms`;
// doormileAxios rejects with the response BODY, not the axios error, so
// `err.response` is undefined here — the status arrives as `err.httpStatus`.
const status = err.httpStatus ?? err.response?.status;
const serverMessage = err.message || err.error;
let message;
if (status === 405) {
// 405 is "the route exists but not this method" — a different fact from
// 404, and worth stating precisely so nobody re-tries the same call.
message = 'POST /admin/tenantcustomers returned 405 — this endpoint does not accept a create. Nothing was saved.';
} else if (status === 404) {
message = 'POST /admin/tenantcustomers returned 404 — that route is not on the server. Nothing was saved.';
} else if (status === 400 || status === 422) {
message = `The server rejected the details${serverMessage ? ` — ${serverMessage}` : ''}. Nothing was saved.`;
} else if (status) {
message = `POST /admin/tenantcustomers returned ${status}${serverMessage ? ` — ${serverMessage}` : ''}. Nothing was saved.`;
} else {
message = `${err.message || 'The request failed'} — the server could not be reached. Nothing was saved.`;
}
return {
ok: false,
status,
message,
sourceCalls: [
{
...call,
duration,
status: 'error',
errorMessage: `${status || 'network'}${serverMessage ? ` · ${serverMessage}` : ''}`
}
]
};
}
};

View File

@@ -0,0 +1,268 @@
import { getMilers, assignMilerToBooking } from '@/api/doormile/endpoints';
import { notifyMiler } from '@/api/doormile/endpoints';
const normMilerName = (n) => (n || '').toString().trim().toLowerCase();
export const buildMilerLookup = (milers) => {
const byUserId = new Map((milers || []).map((m) => [String(m.userid), m]));
const byProfileId = new Map((milers || []).map((m) => [String(m.milerprofileid), m]));
const byName = new Map();
(milers || []).forEach((m) => {
[m.displayname, m.authname].forEach((n) => {
const key = normMilerName(n);
if (key && !byName.has(key)) byName.set(key, m);
});
});
return { byUserId, byProfileId, byName };
};
// ==============================|| Doormile AI — assigning a rider ||============================== //
//
// Fourth write capability. Two endpoints, and which one is correct depends
// entirely on how many orders are being assigned:
//
// ONE order → POST /admin/bookings/:id/assign-miler
// MANY orders → POST /hub/bookings/batch-assign
//
// These are NOT interchangeable. doormile-flow.md §4: batch-assign is "the only
// place stops get ordered" — after assigning it sends each affected rider's
// whole active set to the route optimiser and writes step, per-leg distance and
// ETA back onto `bookingassignments`. Assigning ten orders with ten single
// calls leaves every route unsequenced and riders choosing their own order.
//
// ---- The two rider IDs -----------------------------------------------------
//
// `assign-miler` takes a **mileruserid** in its body. `/admin/milers/:id/notify`
// keys off a **milerprofileid**. Different identity spaces on adjacent
// endpoints — doormile-flow.md calls this out by name. Getting it wrong fails
// quietly in both directions: the assign 404s, or the rider is never told.
// `buildMilerLookup` is the bridge, and it is the SAME one orders.js already
// uses for exactly this translation. Don't grow a second lookup here.
//
// ---- Assignment is normally automatic --------------------------------------
//
// Creating a booking publishes `booking.assignment_requested`; a worker finds a
// rider within 10km via Redis GEO, scores them, and commits — retrying 5 times,
// 2 minutes apart (doormile-flow.md §3). So everything here is an OVERRIDE of a
// decision the backend is already making, which is why the flow re-reads the
// booking's current assignee before offering to change it.
export const ASSIGN_TRIGGER =
/\b(?:re)?assign\s+(?:a\s+|the\s+|another\s+)?(?:rider|miler|driver)\b|\b(?:re)?assign\s+(?:it|this|that|order\b|DM-[A-Za-z0-9-]+)|\bchange\s+(?:the\s+)?rider\b/i;
// Riders, plus the id bridge, in one call.
export const loadRiders = async () => {
const milers = (await getMilers()) || [];
return { milers, lookup: buildMilerLookup(milers) };
};
const riderName = (m) => m.displayname || m.authname || m.name || `Rider #${m.userid}`;
// Available riders first — an operator picking by hand wants the ones who can
// actually take it at the top. Beyond that, alphabetical: any other ordering
// (nearest, least loaded) would need position data this list doesn't carry, and
// a proximity label nobody can stand behind is worse than none.
const isAvailable = (m) => /avail|active|online|free/i.test(String(m.availabilitystatus || ''));
export const riderOptions = (milers) =>
[...(milers || [])]
.sort((a, b) => {
const byAvail = Number(isAvailable(b)) - Number(isAvailable(a));
return byAvail || riderName(a).localeCompare(riderName(b));
})
.map((m) => ({
value: String(m.userid),
label: [riderName(m), m.phone, m.defaultvehicletype, m.availabilitystatus || 'availability unknown'].filter(Boolean).join(' · '),
record: m
}));
// Who currently holds this booking, resolved to a person rather than an id.
export const currentAssignee = (booking, lookup) =>
booking?.assignedmileruserid ? lookup?.byUserId?.get(String(booking.assignedmileruserid)) || null : null;
export const describeRider = (m) => (m ? [riderName(m), m.phone].filter(Boolean).join(' · ') : null);
// ---- one order --------------------------------------------------------------
export const executeAssign = async (booking, rider) => {
const started = Date.now();
const bookingLabel = booking?.bookingno || `#${booking?.bookingid}`;
const call = {
name: 'assignMilerToBooking',
target: `POST /admin/bookings/${booking?.bookingid}/assign-miler`,
stats: `${bookingLabel} → ${riderName(rider)}`
};
try {
// mileruserid, NOT milerprofileid. See the note at the top of this file.
const res = await assignMilerToBooking(booking.bookingid, { mileruserid: Number(rider.userid) });
const duration = `${Date.now() - started}ms`;
if (res && res.success === false) {
return {
ok: false,
message: res.message || 'The server refused the assignment.',
sourceCalls: [{ ...call, duration, status: 'error', errorMessage: res.message }]
};
}
const sourceCalls = [{ ...call, duration, status: 'complete' }];
// CLAUDE.md §9: any mutation that affects a rider is followed by a push.
// It is deliberately NOT allowed to fail the assignment — the order IS
// assigned at this point, and reporting otherwise would be a lie.
// Whether the push ACTUALLY went out, not whether it could have been
// attempted. This used to be reported as `Boolean(rider.milerprofileid)`
// — i.e. "this rider has an id, so assume they were told" — which is a
// different claim entirely. Caught live: notify returned 400 and the
// assistant still said "The rider has been notified."
//
// The assignment itself is unaffected; it had already landed. But a
// dispatcher who believes a rider was pinged does not follow up, and this
// bot's whole contract is that it never states something it has not
// confirmed.
let notified = false;
if (rider.milerprofileid) {
try {
await notifyMiler(rider.milerprofileid);
notified = true;
sourceCalls.push({
name: 'notifyRider',
target: `POST /admin/milers/${rider.milerprofileid}/notify`,
status: 'complete',
stats: 'rider notified'
});
} catch (err) {
sourceCalls.push({
name: 'notifyRider',
target: `POST /admin/milers/${rider.milerprofileid}/notify`,
status: 'error',
errorMessage: err.message || 'notification failed'
});
}
} else {
// No profile id means no push is possible — say so rather than letting
// the operator assume the rider's phone buzzed.
sourceCalls.push({
name: 'notifyRider',
target: '/admin/milers/:id/notify',
status: 'error',
errorMessage: 'this rider has no milerprofileid, so no notification could be sent'
});
}
return { ok: true, rider, bookingLabel, notified, sourceCalls };
} catch (err) {
// doormileAxios rejects with the response BODY; the status rides on
// err.httpStatus.
const status = err.httpStatus;
const message = status
? `POST /admin/bookings/${booking?.bookingid}/assign-miler returned ${status}${
err.message ? ` — ${err.message}` : ''
}. Nothing changed.`
: `${err.message || 'The request failed'} — nothing changed.`;
return {
ok: false,
message,
sourceCalls: [{ ...call, duration: `${Date.now() - started}ms`, status: 'error', errorMessage: message }]
};
}
};
// ---- repeat a run, keeping each order with the rider who ran it last --------
//
// A repeated run is the SAME drops to the SAME doors. The rider who did them
// yesterday already knows the buzzer, the gate code and which side of the
// building to park on, so re-deriving an assignment from scratch throws away
// the one piece of routing knowledge the previous day produced.
//
// Deliberately built on /admin/bookings/:id/assign-miler, one call per order,
// rather than the hub batch endpoint: batch-assign lets the BACKEND choose
// riders, which is the opposite of the intent here, and it is refused to every
// non-hub login anyway (403, confirmed live).
//
// The trade-off this accepts: assigning individually does not sequence a
// rider's stops. Yesterday's run was already sequenced for these same drops,
// so the ordering is not arbitrary — but it is not recomputed either, and the
// caller states that rather than implying a fresh optimisation.
export const executeRepeatAssign = async (createdPairs, rows) => {
const started = Date.now();
// Only rows whose source order actually had a rider. A blank one is not a
// failure — yesterday's copy was never assigned either.
const targets = (createdPairs || [])
.map(({ index, bookingid }) => ({ bookingid, mileruserid: rows?.[index]?.__previousMilerUserId ?? null }))
.filter((t) => t.mileruserid != null);
if (!targets.length) {
return { ok: true, assigned: 0, skipped: (createdPairs || []).length, failures: [], notified: 0, sourceCalls: [] };
}
const { lookup } = await loadRiders().catch(() => ({ lookup: null }));
const assignedRiders = new Set();
const failures = [];
let assigned = 0;
// Sequential on purpose. These are writes against real dispatch records, and
// firing a burst of them concurrently makes a partial failure much harder to
// report accurately — which order did not land, and to whom.
// eslint-disable-next-line no-restricted-syntax
for (const t of targets) {
try {
// eslint-disable-next-line no-await-in-loop
await assignMilerToBooking(t.bookingid, { mileruserid: Number(t.mileruserid) });
assigned += 1;
assignedRiders.add(String(t.mileruserid));
} catch (err) {
failures.push({ bookingid: t.bookingid, reason: err.message || `HTTP ${err.httpStatus || '?'}` });
}
}
const sourceCalls = [
{
name: 'assignMilerToBooking',
target: 'POST /admin/bookings/:id/assign-miler',
duration: `${Date.now() - started}ms`,
status: failures.length ? 'error' : 'complete',
stats: `${assigned} of ${targets.length} re-assigned to yesterday's rider`,
errorMessage: failures.length ? `${failures.length} could not be assigned` : undefined
}
];
// One push per rider, not per order — a rider getting ten of yesterday's
// drops back should feel one buzz, not ten.
let notified = 0;
if (lookup) {
// eslint-disable-next-line no-restricted-syntax
for (const userid of assignedRiders) {
const rider = lookup.byUserId.get(userid);
if (rider?.milerprofileid) {
try {
// eslint-disable-next-line no-await-in-loop
await notifyMiler(rider.milerprofileid);
notified += 1;
} catch {
// Notification failure never fails the assignment — the order IS
// assigned by this point. It is reported, not swallowed.
}
}
}
sourceCalls.push({
name: 'notifyRider',
target: 'POST /admin/milers/:id/notify',
status: notified === assignedRiders.size ? 'complete' : 'error',
stats: `${notified} of ${assignedRiders.size} rider${assignedRiders.size === 1 ? '' : 's'} notified`
});
}
return {
ok: assigned > 0,
assigned,
skipped: (createdPairs || []).length - targets.length,
failures,
notified,
riders: assignedRiders.size,
sourceCalls
};
};

View File

@@ -0,0 +1,200 @@
import { scanBookings } from './intents';
import { ORDER_STATUS_LABELS, groupForBookingStatus } from '@/lib/orderStatusGroups';
import { loadRiders, riderOptions, currentAssignee, describeRider } from './assignActions';
import { advanceFlow, startFlow, answerFlowStep } from './flowEngine';
// ==============================|| Doormile AI — conversational assign ||============================== //
//
// Reached two ways, and the difference is only what the draft is seeded with:
//
// • straight after creating an order — the panel seeds `booking`, so the
// first question is already about a known order
// • "assign a rider to DM-BK-…" — the operator names it, and the first step
// resolves that reference against the real booking list
//
// ---- Why there is a "keep or replace" step ---------------------------------
//
// The backend assigns riders BY ITSELF within seconds of creation and keeps
// retrying for ten minutes (doormile-flow.md §3). By the time an operator
// answers a dropdown, a rider may already hold the order — one the backend
// chose on proximity, which is information this list does not have.
//
// So the flow re-reads the booking's current assignee and, if there is one,
// asks before replacing them. Silently overwriting would throw away a better
// decision and strand a rider who has already been told the job is theirs.
export const ASSIGN_STEPS = [
{
id: 'bookingno',
// A SELECT, not a text field.
//
// This used to ask "Give me its number — for example DM-BK-0D915D43-33705"
// and wait for it to be typed. Two problems with that. An operator does not
// know the number by heart, so the question sent them to another screen to
// go and read one. And it was the fallback reached whenever a create did
// not hand back a booking id — so the moment the API response shape was
// anything other than expected, "assign the order I just made" turned into
// "recite a 20-character reference".
//
// A list removes the failure mode rather than patching it: unassigned
// orders first, because those are the ones anyone is here to assign.
//
// `resolve` below is kept, so typing a number still works — the engine runs
// it for a picked option too, and it already matches on bookingid.
type: 'select',
ask: 'Which order should I assign?',
// The list is built from a live scan, so it can come back empty — an API
// failure, or genuinely no bookings. `resolve` below still accepts a typed
// number, so say that rather than leaving Cancel as the only way out.
emptyHint: 'I couldn’t load the order list. Type the order number instead — for example DM-BK-0D915D43-33705.',
// Seeded by the panel when this follows a create, so it is skipped there —
// that path goes straight to the rider list.
when: (d) => !d.booking,
options: async () => {
const scan = await scanBookings();
const { lookup } = await loadRiders().catch(() => ({ lookup: null }));
return [...(scan.rows || [])]
.map((b) => ({ b, holder: lookup ? currentAssignee(b, lookup) : null }))
// Unassigned first; the API already returns newest-first, and that
// order is preserved within each group by a stable sort.
.sort((x, y) => Number(Boolean(x.holder)) - Number(Boolean(y.holder)))
// A dropdown is for picking, not for browsing. Past this many the
// operator is better served by naming the order.
.slice(0, 30)
.map(({ b, holder }) => ({
value: String(b.bookingid),
label: [
b.bookingno || `#${b.bookingid}`,
// A raw booking carries `status`; `orderstatus` is the mapped
// field the LIST pages add. Reading the wrong one made every
// option in this dropdown say "Unknown" — seen live.
ORDER_STATUS_LABELS[groupForBookingStatus(b.status ?? b.orderstatus)] || 'Unknown',
holder ? `held by ${describeRider(holder)}` : 'unassigned'
]
.filter(Boolean)
.join(' · ')
}));
},
resolve: async (raw) => {
const needle = String(raw || '')
.trim()
.toLowerCase();
if (!needle) return { error: 'I need an order number.' };
const scan = await scanBookings();
// EXACT matches win across the whole list before any fuzzy one is
// considered. The old version tested all three conditions per row inside
// a single find(), so row ORDER decided the winner: a row whose
// bookingno merely CONTAINED the needle could match before the row whose
// bookingid actually equalled it.
//
// That is not theoretical. Picking from the dropdown sends a bare
// numeric bookingid as the answer, and every bookingno ends in a digit
// run — so "32143" substring-matched DM-BK-81DFAF19-32143 while some
// other booking genuinely had id 32143. The assignment then went to a
// different order than the one on screen, which reads as "it said it
// assigned but nothing updated".
const exact = scan.rows.find(
(b) => String(b.bookingid) === needle || String(b.bookingno || '').toLowerCase() === needle
);
// Substring is a convenience for someone typing part of a reference, so
// it needs enough characters to identify one order. Below this it is
// guesswork — "1" would match most of the list.
const MIN_FUZZY = 4;
const found =
exact ||
(needle.length >= MIN_FUZZY
? scan.rows.find((b) => String(b.bookingno || '').toLowerCase().includes(needle))
: null);
if (!found) {
return {
error: scan.truncated
? `I couldn't find ${raw} in the most recent ${scan.scanned.toLocaleString(
'en-IN'
)} bookings. It may be further back than I can scan.`
: `I couldn't find an order matching ${raw}. Check the number and try again.`
};
}
// Resolve the current holder HERE, not afterwards. advanceFlow evaluates
// keepOrReplace's `when` the instant this step is applied — a lookup that
// lands even one tick later means the step is skipped and an
// already-assigned order is silently reassigned.
const { lookup } = await loadRiders().catch(() => ({ lookup: null }));
return { value: { booking: found, currentRider: lookup ? currentAssignee(found, lookup) : null } };
},
apply: (d, v) => ({ ...d, booking: v.booking, __currentRider: v.currentRider })
},
{
id: 'keepOrReplace',
type: 'select',
// The question text is rewritten by the panel to name the current holder;
// this is the fallback if that lookup came back empty.
ask: 'This order already has a rider. Keep them, or assign someone else?',
when: (d) => Boolean(d.__currentRider),
options: async (d) => [
{ value: 'keep', label: `Keep ${describeRider(d.__currentRider) || 'the current rider'}` },
{ value: 'replace', label: 'Assign someone else' }
],
apply: (d, v) => ({ ...d, keepOrReplace: v, __keep: v === 'keep' })
},
{
id: 'mileruserid',
type: 'select',
ask: 'Which rider should take it?',
when: (d) => !d.__keep,
options: async () => {
const { milers } = await loadRiders();
return riderOptions(milers);
},
// Resolved rather than taken straight from the clicked option.
//
// `apply` used to read `option?.record`, which is only populated when a
// button was CLICKED. Answer this step by typing — a rider's name, or an
// id — and option is undefined, so __rider was undefined, and executeAssign
// then threw on `rider.userid` inside its try/catch. The operator saw a
// failed assignment with "Cannot read properties of undefined" instead of
// an answer.
//
// Resolving here means both paths produce a real miler record, and a name
// that matches nobody gets a sentence rather than a crash.
resolve: async (raw) => {
const needle = String(raw || '').trim();
if (!needle) return { error: 'I need a rider.' };
const { milers, lookup } = await loadRiders();
const rider =
lookup?.byUserId?.get(needle) ||
lookup?.byName?.get(needle.toLowerCase()) ||
(milers || []).find((m) => String(m.userid) === needle) ||
(milers || []).find((m) => (m.displayname || m.authname || '').toLowerCase() === needle.toLowerCase());
if (!rider) return { error: `I couldn't find a rider matching ${raw}. Pick one from the list.` };
// Both ids travel: assign-miler needs `userid`, the push needs
// `milerprofileid`, and they are different fields on the same record.
return { value: { mileruserid: String(rider.userid), rider } };
},
apply: (d, v) => ({ ...d, mileruserid: v.mileruserid, __rider: v.rider })
}
];
// `booking` is optional — present when this follows a create.
export const startAssignFlow = async (booking) => {
const draft = booking ? { booking } : {};
if (booking) {
// Resolve who holds it right now, so the keep/replace step knows whether to
// ask at all. A failure here degrades to "nobody assigned yet", which is
// the safe direction: the operator is asked to choose rather than being
// told something untrue about the current rider.
const { lookup } = await loadRiders().catch(() => ({ lookup: null }));
const held = lookup ? currentAssignee(booking, lookup) : null;
if (held) draft.__currentRider = held;
}
return startFlow(ASSIGN_STEPS, 'assignRider', draft);
};
export const advanceAssign = (flow) => advanceFlow(ASSIGN_STEPS, flow);
export const answerAssignStep = (flow, raw, option) => answerFlowStep(ASSIGN_STEPS, flow, raw, option);

View File

@@ -0,0 +1,223 @@
import Papa from 'papaparse';
import * as XLSX from 'xlsx';
import { requiredSheetColumns, normalizeHeader, rowFieldForHeader, mapSheetRow, TEMPLATE_HEADERS } from '@/lib/bulkOrderColumns';
// ==============================|| Doormile AI — bulk order file upload ||============================== //
//
// Turns a CSV / XLS / XLSX into the SAME row array `parseBulkRows` produces from
// a paste, so everything downstream — geocoding, validation, review, the chunked
// submit, the per-row outcome report — is untouched by where the rows came from.
//
// Both parsers are already dependencies (`papaparse`, `xlsx`) and both are the
// ones multipleOrders.js uses, as is the column map. A sheet that uploads on
// that page uploads here.
//
// Three reporting rules, all of them about not lying by omission:
//
// • An unparseable row becomes a REPORTED error with its line number, never a
// silently skipped line. A bulk import that quietly drops row 14 is worse
// than one that refuses outright.
// • Columns that were not recognised are NAMED. An operator whose price
// column is titled something unexpected has to be told it was ignored, or
// they'll submit 200 orders priced from a column nothing ever read.
// • Rows duplicated inside the file are flagged BEFORE submit. The bulk
// endpoint has no idempotency key, so a duplicate that gets through is a
// second real rider dispatched to the same door.
const CSV_EXT = /\.csv$/i;
const EXCEL_EXT = /\.xlsx?$/i;
const digits = (v) => String(v ?? '').replace(/\D/g, '');
const text = (v) => String(v ?? '').trim();
// A sheet cell can be a number, a date, or padded text — normalise to the same
// shapes parseBulkRows yields so validateBulkRow behaves identically.
const shapeRow = (raw, line) => {
const { row, ignored } = mapSheetRow(raw);
const lat = Number(row.deliverylatitude);
const lng = Number(row.deliverylongitude);
const hasCoords = Number.isFinite(lat) && Number.isFinite(lng) && lat !== 0 && lng !== 0;
return {
ignored,
row: {
line,
customer_name: text(row.customer_name),
// A 10-digit Indian mobile arrives as 9812345678, 09812345678, +91
// 98123 45678, or — from Excel — 9812345678 as a float. Strip to digits
// and drop a leading country/trunk prefix the same way the single-order
// flow does.
customer_phone: digits(row.customer_phone).replace(/^(?:0|91)(?=\d{10}$)/, ''),
deliveryaddress: text(row.deliveryaddress),
deliverypincode: digits(row.deliverypincode),
deliverycity: text(row.deliverycity),
// Blank is meaningful: it means "quote this row from the tenant's pricing
// row and the routed distance", the same as the single-order flow. It is
// NOT zero.
finalprice: text(row.finalprice),
itemdescription: text(row.itemdescription) || 'Order',
itemcategory: text(row.itemcategory) || 'General',
quantity: Math.max(1, Number(row.quantity) || 1),
weight: text(row.weight),
// Coordinates from the sheet let the geocode pass skip this row entirely,
// which on a 200-row file is the difference between minutes and seconds.
...(hasCoords ? { deliverylatitude: lat, deliverylongitude: lng, resolvedAddress: text(row.deliveryaddress) } : {})
}
};
};
// Structural check only — enough to know the row is worth geocoding. The full
// gate is validateBulkRow, applied after coordinates exist.
const structuralError = (row) => {
if (!row.customer_name) return 'No receiver name';
if (!row.deliveryaddress) return 'No delivery address';
if (!row.customer_phone) return 'No phone number';
if (row.finalprice !== '' && Number.isNaN(Number(row.finalprice))) return `Price "${row.finalprice}" is not a number`;
return null;
};
export const mapSheetRecords = (records, headers, sheetName) => {
const rows = [];
const errors = [];
const ignoredColumns = new Set();
records.forEach((raw, i) => {
// +2: the header row is line 1, so the first data row is line 2 — the line
// number an operator sees in their own spreadsheet.
const line = i + 2;
const { row, ignored } = shapeRow(raw, line);
ignored.forEach((c) => ignoredColumns.add(c));
// A trailing blank row is an artefact of the file, not an operator error.
if (!row.customer_name && !row.deliveryaddress && !row.customer_phone) return;
const error = structuralError(row);
if (error) errors.push({ line, text: row.customer_name || row.deliveryaddress || `Row ${line}`, reason: error });
else rows.push(row);
});
const normalised = headers.map(normalizeHeader);
const missingRequired = requiredSheetColumns().filter((c) => !normalised.includes(normalizeHeader(c)));
return {
rows,
errors,
sheetName,
ignoredColumns: [...ignoredColumns],
// Reported, not enforced: the page only warns about these too, and a
// hand-built sheet using plain headers ("name", "phone") legitimately has
// none of the tenant's official titles while still being complete.
missingRequired,
recognisedColumns: headers.filter((h) => rowFieldForHeader(h)).map((h) => String(h).trim()),
duplicates: findDuplicateRows(rows)
};
};
// Same recipient at the same address twice in one file. Reported, never removed
// automatically — two parcels to one door is a legitimate order, and deciding
// which is which is the operator's call, not the parser's.
export const findDuplicateRows = (rows) => {
const seen = new Map();
const dupes = [];
rows.forEach((r) => {
const key = `${r.customer_phone}|${normalizeHeader(r.deliveryaddress)}`;
if (seen.has(key)) dupes.push({ line: r.line, firstLine: seen.get(key), customer_name: r.customer_name });
else seen.set(key, r.line);
});
return dupes;
};
export const parseBulkFile = (file) =>
new Promise((resolve, reject) => {
if (!file) {
reject(new Error('No file selected.'));
return;
}
const isCsv = CSV_EXT.test(file.name);
const isExcel = EXCEL_EXT.test(file.name);
if (!isCsv && !isExcel) {
reject(new Error(`“${file.name}” isn’t a spreadsheet. Upload a .csv, .xls or .xlsx file.`));
return;
}
if (isCsv) {
Papa.parse(file, {
header: true,
dynamicTyping: false,
skipEmptyLines: true,
complete: (results) => {
if (!results.data?.length) {
reject(new Error('That CSV has a header row but no data rows.'));
return;
}
resolve(mapSheetRecords(results.data, results.meta.fields || [], file.name));
},
error: (err) => reject(new Error(`Couldn’t read that CSV — ${err.message}`))
});
return;
}
const reader = new FileReader();
reader.onerror = () => reject(new Error('Couldn’t read that file.'));
reader.onload = (e) => {
try {
const workbook = XLSX.read(e.target.result, { type: 'binary' });
const sheetName = workbook.SheetNames[0];
// Only the first sheet is read, and the name is reported back so an
// operator whose data sits on "Sheet2" can see which one was used.
const records = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { defval: '', raw: false });
if (!records?.length) {
reject(new Error(`Sheet “${sheetName}” is empty.`));
return;
}
resolve(mapSheetRecords(records, Object.keys(records[0]), `${file.name} · ${sheetName}`));
} catch (err) {
reject(new Error(`Couldn’t read that spreadsheet — ${err.message}`));
}
};
reader.readAsBinaryString(file);
});
// ---- downloads --------------------------------------------------------------
// Hands the operator a file built from data they already supplied — a Blob
// assembled in the page, not a fetch and not an upload.
export const downloadCsv = (filename, csv) => {
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8;' }));
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
};
const toCsv = (headers, rows) =>
[headers, ...rows]
.map((r) => r.map((c) => (/[",\n]/.test(String(c ?? '')) ? `"${String(c).replace(/"/g, '""')}"` : String(c ?? ''))).join(','))
.join('\r\n');
// A blank sheet with the exact headers this parser reads, so operators stop
// guessing at column titles.
export const templateCsv = () =>
toCsv(TEMPLATE_HEADERS, [['Ravi Kumar', '9812345678', '12 Trichy Rd, Coimbatore', '641018', 'Coimbatore', 'Documents', '1', '']]);
// The rows that did NOT go through, in the same column shape, so they can be
// fixed and re-uploaded. This is what makes a partial success recoverable
// without re-submitting the rows that already landed.
export const failedRowsCsv = (failed) =>
toCsv(
[...TEMPLATE_HEADERS, 'Reason'],
failed.map((r) => [
r.customer_name || '',
r.customer_phone || '',
r.deliveryaddress || '',
r.deliverypincode || '',
r.deliverycity || '',
r.itemdescription || '',
r.quantity ?? 1,
r.finalprice ?? '',
r.error || r.reason || 'Rejected'
])
);

View File

@@ -0,0 +1,193 @@
import { getTenantLocations } from '@/api/doormile/endpoints';
import { getAdminTenants } from '@/api/doormile/endpoints';
import { geocodeAddress } from '@/components/doormile/AddressAutocomplete';
import { SERVICE_OPTIONS, cityGateFor } from './orderActions';
import { validateBulkRow, priceBulkRows, BULK_MAX } from './bulkOrderActions';
import { advanceFlow, startFlow, answerFlowStep } from './flowEngine';
// ==============================|| Doormile AI — conversational bulk create ||============================== //
//
// Same conversation shape as orderFlow.js — one question per turn, a dropdown
// wherever the page uses one — but the rows come from a sheet instead of being
// dictated one field at a time. The form version was replaced on explicit
// direction: "don't show it as the form way, it should be like chatting".
//
// The steps deliberately mirror the single-order flow's opening, because they
// ARE the same questions: which tenant, which pickup location, which service.
// Only the last step differs — a whole file instead of one recipient.
//
// What is NOT a step: locating and pricing the rows. Those are a long-running
// pass over the whole file (~1 lookup/second), so the panel runs them after the
// last answer and reports progress into the conversation. Making them a "step"
// would mean a question nobody is being asked.
const isStaffLogin = () => {
const t = localStorage.getItem('tenantid');
return !t || t === '0';
};
export const BULK_STEPS = [
{
id: 'tenantid',
type: 'select',
ask: 'Which tenant are these orders for?',
when: () => isStaffLogin(),
options: async () => {
const tenants = (await getalltenants()) || [];
return tenants.map((t) => ({ value: String(t.tenantid), label: t.tenantname || `Tenant #${t.tenantid}` }));
},
apply: (d, v) => ({ ...d, tenantid: v })
},
{
id: 'pickuplocationid',
type: 'select',
// One pickup location for the whole file — the same shape the bulk page
// uses, and what makes a single batch dispatchable.
ask: 'Which business location are they all picked up from?',
options: async (d) => {
const tid = d.tenantid || localStorage.getItem('tenantid');
const locations = (await getTenantLocations(tid)) || [];
return locations.map((l) => ({
value: String(l.locationid),
label: `${l.locationname || l.address || 'Location'}${
l.pincode ? ` · ${l.pincode}${cityGateFor(l.pincode) ? '' : ' (closed city)'}` : ''
}`,
record: l
}));
},
// Refused here rather than after submitting: CityGate runs server-side
// before the handler, and would reject every row in the file with an
// opaque middleware error.
validate: (v, option) =>
cityGateFor(option?.record?.pincode)
? null
: `That location’s pincode (${
option?.record?.pincode || 'unknown'
}) is outside the cities Doormile serves, so every row would be refused. Pick another location.`,
apply: (d, v, option) => ({ ...d, pickuplocationid: v, __pickup: option?.record })
},
{
id: 'service_option',
type: 'select',
ask: 'Which service level for all of them?',
options: async () => SERVICE_OPTIONS.map((o) => ({ value: o, label: o })),
apply: (d, v) => ({ ...d, service_option: v })
},
{
id: 'rows',
type: 'rows',
ask: 'Now the orders themselves — upload a sheet, or paste the rows.',
// The whole parse result is stored, not just the rows: the ignored columns
// and in-file duplicates have to be reportable, and a count of rows alone
// can't say what was quietly not read.
validate: (parsed) =>
parsed?.rows?.length
? null
: 'I couldn’t read any complete rows out of that. Every row needs at least a name, a phone and an address.',
apply: (d, parsed) => ({ ...d, rows: parsed.rows, __parse: parsed })
}
];
export const startBulkFlow = () => {
// Same seeding rule as the single-order flow: a client login skips the tenant
// question, so the id has to be in the draft or the payload sends NaN.
const tid = localStorage.getItem('tenantid');
return startFlow(BULK_STEPS, 'createBulk', tid && tid !== '0' ? { tenantid: tid } : {});
};
export const advanceBulk = (flow) => advanceFlow(BULK_STEPS, flow);
export const answerBulkStep = (flow, raw, option) => answerFlowStep(BULK_STEPS, flow, raw, option);
// ---- the long pass: locate, then price --------------------------------------
//
// Extracted from the old form so the conversation can run it and narrate it.
// Two economies keep a large file practical, and both are load-bearing:
//
// • a sheet carrying latitude/longitude columns skips the lookup entirely
// • results are cached by address, so a re-run after fixing a few rows does
// not re-look-up the ones that were already fine
//
// `shouldStop` is read through a function, never a captured boolean — as state
// it was evaluated once at call time and Stop did nothing for 200 rows.
export const GEOCODE_INTERVAL_MS = 1100;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export const cacheKey = (row) => `${String(row.deliveryaddress || '').toLowerCase()}|${row.deliverypincode || ''}`;
export const hasCoords = (row) => Number.isFinite(Number(row.deliverylatitude)) && Number.isFinite(Number(row.deliverylongitude));
export const resolveBulkRows = async (rows, { pickup, tenantid, cache, onProgress, shouldStop } = {}) => {
const located = [];
for (let i = 0; i < rows.length; i += 1) {
if (shouldStop?.()) break;
const row = rows[i];
if (hasCoords(row)) {
located.push(row);
// eslint-disable-next-line no-continue
continue;
}
const key = cacheKey(row);
if (cache?.has(key)) {
located.push({ ...row, ...cache.get(key) });
// eslint-disable-next-line no-continue
continue;
}
onProgress?.({ phase: 'locate', done: i, total: rows.length, current: row.deliveryaddress });
// eslint-disable-next-line no-await-in-loop
const place = await geocodeAddress(`${row.deliveryaddress} ${row.deliverypincode}`).catch(() => null);
const found = {
deliverylatitude: place?.geometry?.location?.lat?.(),
deliverylongitude: place?.geometry?.location?.lng?.(),
resolvedAddress: place?.formatted_address
};
cache?.set(key, found);
located.push({ ...row, ...found });
// Only wait after a real request. A cache hit or a sheet coordinate costs
// nothing, which is what makes a re-run fast.
// eslint-disable-next-line no-await-in-loop
if (i < rows.length - 1) await sleep(GEOCODE_INTERVAL_MS);
}
// Rows never reached because Stop was pressed keep no coordinates, so they
// report as unsendable instead of vanishing from the count.
if (located.length < rows.length) located.push(...rows.slice(located.length));
// Only a row that is located AND unpriced needs a routing call. Pricing an
// unlocatable row spends an OSRM request just to fail, and re-pricing a row
// that carried its own price would overwrite the operator's number.
const needPricing = located.filter((r) => hasCoords(r) && String(r.finalprice ?? '') === '');
// Stop deliberately does NOT gate this phase. It exists to stop the ~1/second
// ADDRESS lookups; pricing is unthrottled and bounded by what was already
// located. Gating it here meant a Stop mid-lookup left every located row
// unpriced and therefore unsendable — throwing away exactly the work the
// operator is told is kept.
const priced = needPricing.length
? await priceBulkRows(needPricing, pickup, tenantid, { onProgress: (p) => onProgress?.({ ...p, phase: 'price' }) })
: [];
const pricedByLine = new Map(priced.map((r) => [r.line, r]));
const checked = located.map((r) => {
const merged = pricedByLine.get(r.line) || r;
return {
...merged,
// The pricing reason is more specific than "Price must be a number", so it
// wins when both apply.
error: merged.priceError ? `Couldn’t price it — ${merged.priceError}` : validateBulkRow(merged)
};
});
return {
rows: checked,
valid: checked.filter((r) => !r.error),
invalid: checked.filter((r) => r.error)
};
};
// How many rows still need a network lookup — the only honest basis for an ETA.
export const lookupsNeeded = (rows, cache) => rows.filter((r) => !hasCoords(r) && !cache?.has(cacheKey(r))).length;
export const batchCount = (n) => Math.ceil(n / BULK_MAX);

View File

@@ -0,0 +1,299 @@
import { createExpressBookingBulk, getAdminPricing } from '@/api/doormile/endpoints';
import { calculateDrivingDistance, calculateTotalCharge } from '@/lib/distance';
import { buildOrderPayload } from './orderActions';
// ==============================|| Doormile AI — bulk order creation ||============================== //
//
// Third write capability, and the highest-blast-radius one: a single press can
// dispatch dozens of riders. Everything here is built around making that
// visible BEFORE it happens and legible AFTER.
//
// Shared with the single-order path on purpose:
// • buildOrderPayload — so a bulk row and a single order are byte-identical
// on the wire, including the pickuplocationid workaround (that field 500s
// server-side; raw pickup fields are sent instead).
// • validateOrderDraft — the same gate, applied per row.
// Server cap, documented in express-console-api.md. Exceeding it is a hard
// error rather than a silent truncation, so rows are chunked instead.
export const BULK_MAX = 200;
export const CREATE_BULK_TRIGGER =
/\b(?:create|add|place|book|new|bulk|multiple)\s+(?:multiple|many|several|bulk|\d+)\s*(?:orders|bookings|deliveries)\b|\bbulk\s+(?:order|booking|upload)\b|\bmultiple\s+orders\b/i;
// ---- Parsing a pasted list --------------------------------------------------
//
// Operators paste from a spreadsheet, so accept the shapes that actually
// arrive: comma, tab or pipe separated, one row per line, with an optional
// header row.
//
// name, phone, address, pincode, city, price, description
//
// Anything unparseable becomes a REPORTED row error rather than a silently
// dropped line — a bulk import that quietly skips row 14 is worse than one
// that refuses.
const SPLIT = /\t|\||,(?![^(]*\))/;
const HEADER_HINT = /name|phone|mobile|address|pincode|city|price|amount|item|description/i;
export const parseBulkRows = (text) => {
const lines = String(text || '')
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
if (!lines.length) return { rows: [], errors: [] };
// Drop a header row only when it looks like one AND carries no phone number.
const first = lines[0];
const looksLikeHeader = HEADER_HINT.test(first) && !/\d{10}/.test(first);
const body = looksLikeHeader ? lines.slice(1) : lines;
const rows = [];
const errors = [];
body.forEach((line, i) => {
const parts = line.split(SPLIT).map((p) => p.trim());
const lineNo = (looksLikeHeader ? 2 : 1) + i;
if (parts.length < 4) {
errors.push({ line: lineNo, text: line, reason: 'Needs at least name, phone, address and pincode' });
return;
}
const [customer_name, customer_phone, deliveryaddress, deliverypincode, deliverycity, finalprice, itemdescription] = parts;
rows.push({
line: lineNo,
customer_name,
customer_phone: String(customer_phone || '').replace(/\D/g, ''),
deliveryaddress,
deliverypincode: String(deliverypincode || '').replace(/\D/g, ''),
deliverycity: deliverycity || '',
finalprice: finalprice || '',
itemdescription: itemdescription || 'Order',
itemcategory: 'General',
quantity: 1
});
});
return { rows, errors };
};
// Per-row validation. Deliberately NOT validateOrderDraft: a pasted row has no
// coordinates (there is no address search on a paste), and the single-order
// gate requires them. Bulk rows are geocoded by the caller before submit, and
// rows that fail to geocode are reported, not sent.
const PHONE_RE = /^\d{10}$/;
export const validateBulkRow = (row) => {
if (!row.customer_name) return 'Missing customer name';
if (!PHONE_RE.test(row.customer_phone)) return 'Phone must be exactly 10 digits';
if (!row.deliveryaddress) return 'Missing delivery address';
if (!row.deliverypincode) return 'Missing delivery pincode';
// Coordinates are checked BEFORE the price, because an unlocatable address is
// the root cause and a blank price is its symptom — the row was never priced
// precisely because there was nothing to route. Reporting "price must be a
// number" here sent the operator to fix the wrong column.
if (!Number.isFinite(Number(row.deliverylatitude)) || !Number.isFinite(Number(row.deliverylongitude))) {
return 'Address could not be located — the order could not be routed';
}
if (row.finalprice === '' || Number.isNaN(Number(row.finalprice))) return 'Price must be a number';
return null;
};
// ---- per-row pricing --------------------------------------------------------
//
// A blank price column means "quote it", exactly as the single-order flow does —
// not zero. The tenant's pricing row is fetched ONCE for the whole file (fetching
// it per row would be 200 identical requests), then each unpriced row costs one
// OSRM call for its routed distance.
//
// A row that can't be priced keeps its blank price and carries the reason. It
// then fails validateBulkRow and is reported, rather than being submitted at a
// number nobody chose.
export const priceBulkRows = async (rows, pickup, tenantid, { onProgress, shouldStop } = {}) => {
const pricing = (await getAdminPricing()) || [];
// tenantid is numeric on the pricing row and a string from localStorage — a
// strict comparison here silently priced every order at zero once before.
const match = pricing.find((p) => String(p.tenantid) === String(tenantid));
const out = [];
for (let i = 0; i < rows.length; i += 1) {
const row = rows[i];
// A stop leaves the remaining rows exactly as they were — unpriced and
// therefore invalid — instead of half-pricing the file.
if (shouldStop?.()) {
out.push(...rows.slice(i));
break;
}
onProgress?.({ done: i, total: rows.length, current: row.customer_name || row.deliveryaddress });
if (String(row.finalprice ?? '') !== '') {
out.push(row);
// eslint-disable-next-line no-continue
continue;
}
if (!match) {
out.push({ ...row, priceError: 'no pricing configured for this tenant' });
// eslint-disable-next-line no-continue
continue;
}
// eslint-disable-next-line no-await-in-loop
const km = await calculateDrivingDistance(
{ latitude: pickup?.latitude, longitude: pickup?.longitude },
{ latitude: row.deliverylatitude, longitude: row.deliverylongitude }
).catch(() => null);
if (km == null) {
out.push({ ...row, priceError: 'could not measure the distance' });
// eslint-disable-next-line no-continue
continue;
}
const total = calculateTotalCharge(km, match.baseprice, match.priceperkm, match.basedistance);
out.push({ ...row, finalprice: Number(Number(total).toFixed(2)), km, quoted: true });
}
return out;
};
// ---- double-submit protection ----------------------------------------------
//
// POST /admin/expressbooking/bulk takes no idempotency key, so if a submit times
// out the operator cannot tell what landed — and re-sending the file double-books
// every row that succeeded. Fingerprints of what has already been submitted this
// session are kept so an identical re-submit can at least be questioned.
//
// Session-scoped on purpose: it guards the realistic accident (pressing Create
// twice, or re-uploading the same file minutes later), not a next-day re-run,
// which may be a legitimately repeated delivery round.
const submitted = new Set();
export const rowSetFingerprint = (rows) =>
(rows || [])
.map((r) => `${r.customer_phone}|${r.deliverypincode}|${r.finalprice}`)
.sort()
.join(';');
export const wasAlreadySubmitted = (rows) => rows?.length > 0 && submitted.has(rowSetFingerprint(rows));
const chunk = (arr, size) => {
const out = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
};
// The only bulk-writing function in the assistant.
//
// Returns a per-row outcome, never a bare success/failure. A partial success
// is the normal case for a bulk import, and the operator has to be able to see
// exactly which rows landed — otherwise the only safe response to any error is
// to assume nothing worked and re-submit everything, which double-books.
export const executeCreateBulk = async (rows, shared) => {
const started = Date.now();
// Recorded BEFORE the request, not after: a timed-out submit is the case that
// most needs the warning, and it never reaches a success handler.
submitted.add(rowSetFingerprint(rows));
// A row may carry its OWN pickup. A bulk FILE shares one kitchen, but a
// repeated day's run can span several tenants and locations — collapsing
// those onto one shared pickup would silently re-address half the orders.
const payloads = rows.map((r) => buildOrderPayload({ ...shared, ...r }, r.__pickup ?? shared.__pickup));
const batches = chunk(payloads, BULK_MAX);
const sourceCalls = [];
let created = 0;
const failures = [];
// The ids of what actually landed, so the run can be handed straight to
// batch-assign without a re-scan of /admin/bookings to find them again.
const createdIds = [];
// {index, bookingid} — the same ids, but each still tied to its source row.
const createdPairs = [];
for (let b = 0; b < batches.length; b += 1) {
const batch = batches[b];
const label = batches.length > 1 ? ` (batch ${b + 1}/${batches.length})` : '';
try {
// eslint-disable-next-line no-await-in-loop
const res = await createExpressBookingBulk(batch);
// Three shapes, most-nested first. The live endpoint returns
// { data: { results: [ { index, success, bookingid, bookingno } ] } }
// — confirmed against api.doormile.com — and only the two flatter shapes
// were checked here. So `res.data` was an object rather than an array,
// `res.results` was undefined, perRow fell through to null, and the
// whole run was treated as all-or-nothing: the count came out right by
// accident (created += batch.length) while EVERY booking id was thrown
// away. That is why "Assign the 13 you just created" never appeared —
// there were no ids to offer.
const perRow = Array.isArray(res?.data?.results)
? res.data.results
: Array.isArray(res?.data)
? res.data
: Array.isArray(res?.results)
? res.results
: null;
if (res?.success === false) {
failures.push(...batch.map((_, i) => ({ index: b * BULK_MAX + i, reason: res.message || 'Rejected' })));
sourceCalls.push({
name: 'createExpressBookingBulk',
target: `POST /admin/expressbooking/bulk${label}`,
status: 'error',
errorMessage: res.message || 'Rejected'
});
// eslint-disable-next-line no-continue
continue;
}
// The endpoint is documented as returning per-row results. If it does,
// trust it row by row; if it doesn't, treat the batch as all-or-nothing
// rather than inventing a success count.
if (perRow) {
perRow.forEach((r, i) => {
const index = b * BULK_MAX + i;
if (r?.success === false || r?.error) failures.push({ index, reason: r.message || r.error || 'Rejected' });
else {
created += 1;
// Paired with the row it came from, not just collected. A bare list
// of ids cannot say WHICH row produced which booking, and the
// repeat flow needs exactly that to hand each new order back to the
// rider who ran it last time.
if (r?.bookingid) {
createdIds.push(r.bookingid);
createdPairs.push({ index, bookingid: r.bookingid });
}
}
});
} else {
created += batch.length;
}
sourceCalls.push({
name: 'createExpressBookingBulk',
target: `POST /admin/expressbooking/bulk${label}`,
status: 'complete',
stats: `${batch.length} submitted`
});
} catch (err) {
// doormileAxios rejects with the response BODY; the status is attached
// as `err.httpStatus`.
const status = err.httpStatus;
const reason = err.message || 'Request failed';
failures.push(...batch.map((_, i) => ({ index: b * BULK_MAX + i, reason })));
sourceCalls.push({
name: 'createExpressBookingBulk',
target: `POST /admin/expressbooking/bulk${label}`,
status: 'error',
errorMessage: `${status || 'network'} · ${reason}`
});
}
}
return {
ok: created > 0,
created,
// Empty when the endpoint returned no per-row array — the caller must treat
// "no ids" as "cannot offer assignment", not as "nothing was created".
createdIds,
createdPairs,
failed: failures.length,
failures,
batches: batches.length,
sourceCalls: sourceCalls.map((c) => ({ ...c, duration: `${Date.now() - started}ms` }))
};
};

View File

@@ -0,0 +1,142 @@
import { parseCustomerDraft, validateCustomerDraft, buildCustomerPayload } from './actions';
// ==============================|| Doormile AI — conversational create-customer ||============================== //
//
// Asks for one field at a time, then shows what it will send and waits for
// Submit.
//
// ---- Why this lives here and not in the router ------------------------------
//
// A first attempt at this shipped and broke immediately: the operator typed
// "create customer", was asked for a phone number, replied "8494948494", and
// got "I can't answer that one yet".
//
// The cause was architectural, not a typo. `answerQuestion` picks an intent by
// MATCHING THE TEXT — and a bare phone number matches nothing, so the reply was
// routed to the fallback and discarded. Threading a partial draft through the
// router's `context` didn't help, because the router had already failed to
// choose an intent before the draft was ever consulted.
//
// So the conversation is owned by the PANEL, which checks for an active flow
// BEFORE calling the router at all. A reply mid-flow is never routed. That is
// the only arrangement where "8494948494" can't be misread as a question.
//
// Field set and validation mirror pages/nearle/clients/createCustomer.js:
// name and a 10-digit phone are required, everything else is optional and
// skippable.
const PHONE_RE = /^\d{10}$/;
const EMAIL_RE = /^[\w.+-]+@[\w-]+\.[\w.]{2,}$/;
// "skip", "none", "no", "-" all mean "leave it blank". Without this the
// operator has no way past an optional field except inventing a value.
const SKIP_RE = /^(?:skip|none|no|n\/a|na|-|nil)$/i;
export const isSkip = (text) => SKIP_RE.test(String(text || '').trim());
// Ordered. `ask` is the question; `apply` folds the answer into the draft;
// `validate` returns an error string to re-ask with, or null to accept.
export const CUSTOMER_STEPS = [
{
field: 'name',
ask: 'What’s the customer’s name?',
required: true,
apply: (draft, text) => {
const [firstname, ...rest] = String(text).trim().split(/\s+/);
return { ...draft, firstname, lastname: rest.join(' ') || undefined };
},
validate: (text) => (String(text).trim().length >= 2 ? null : 'I need a name — at least two characters.')
},
{
field: 'phone',
ask: 'And their 10-digit mobile number?',
required: true,
apply: (draft, text) => ({ ...draft, phone: String(text).replace(/\D/g, '') }),
// Validated against the digits only, so "98765 43210" and "+91 9876543210"
// are both accepted rather than rejected on formatting.
validate: (text) => {
const digits = String(text)
.replace(/\D/g, '')
.replace(/^91(?=\d{10}$)/, '');
return PHONE_RE.test(digits) ? null : 'That doesn’t look like 10 digits — try again.';
}
},
{
field: 'email',
ask: 'Email address? (say “skip” if there isn’t one)',
apply: (draft, text) => ({ ...draft, email: String(text).trim() }),
validate: (text) => (EMAIL_RE.test(String(text).trim()) ? null : 'That doesn’t look like an email — or say “skip”.')
},
{
field: 'address',
ask: 'Address? (or “skip”)',
apply: (draft, text) => ({ ...draft, address: String(text).trim() })
},
{
field: 'city',
ask: 'City? (or “skip”)',
apply: (draft, text) => ({ ...draft, city: String(text).trim() })
},
{
field: 'postcode',
ask: 'Postcode? (or “skip”)',
apply: (draft, text) => ({ ...draft, postcode: String(text).replace(/\D/g, '') })
}
];
// Starts the flow, pre-filling anything already said in the opening message —
// "create a customer Ramesh 9876543210" should not then ask for the name and
// the phone it was just given.
export const startCustomerFlow = (text) => {
const draft = parseCustomerDraft(text);
return advance({ kind: 'createCustomer', step: 0, draft });
};
// Moves to the next step that still needs an answer. Returns either a question
// to ask, or the finished proposal.
export const advance = (flow) => {
let { step } = flow;
const { draft } = flow;
while (step < CUSTOMER_STEPS.length) {
const s = CUSTOMER_STEPS[step];
const already = s.field === 'name' ? draft.firstname : draft[s.field];
if (already) {
step += 1;
// eslint-disable-next-line no-continue
continue;
}
return { flow: { ...flow, step }, ask: s.ask, done: false };
}
// Every step visited. The required check is repeated here rather than
// trusted from the walk above, so a skipped-but-required field can never
// reach a proposal.
const { ok, missing } = validateCustomerDraft(draft);
if (!ok) {
return { flow: { ...flow, step: 0 }, ask: `I still need ${missing.join(' and ')}. What’s the name?`, done: false };
}
return { flow: { ...flow, step, complete: true }, done: true, payload: buildCustomerPayload(draft) };
};
// Applies one answer. Returns the next question, or the completed proposal, or
// a re-ask when the answer didn't validate.
export const answerStep = (flow, text) => {
const s = CUSTOMER_STEPS[flow.step];
if (!s) return advance(flow);
if (isSkip(text)) {
if (s.required) return { flow, ask: `Sorry — ${s.field === 'name' ? 'a name' : 'this'} is required. ${s.ask}`, done: false };
// Skipped optional field: step past it without writing anything, so the
// payload builder drops it rather than sending an empty string.
return advance({ ...flow, step: flow.step + 1 });
}
const error = s.validate?.(text);
if (error) return { flow, ask: error, done: false, retry: true };
return advance({ ...flow, step: flow.step + 1, draft: s.apply(flow.draft, text) });
};
// Human-readable summary of what will be sent, for the confirm step.
export const describePayload = (payload) => Object.entries(payload).map(([k, v]) => ({ label: k, meta: String(v) }));

View File

@@ -0,0 +1,83 @@
// ==============================|| Doormile AI — conversational flow engine ||============================== //
//
// One step-walker, shared by every conversational create (orderFlow.js,
// bulkFlow.js). It was written inside orderFlow and extracted when the bulk
// create became a conversation too — a second copy would have been a third
// definition of the same branching rules to keep in sync.
//
// A step is a plain object:
//
// id required. Also the draft key the answer lands on.
// type 'select' → the panel renders a dropdown (AIFlowStep)
// 'rows' → the panel renders the file/paste input
// 'text' → answered through the composer
// ask the question
// when (draft) => boolean. Omitted means always asked. THIS is branching.
// options async (draft) => [{ value, label, record? }] — for 'select'
// validate (raw, option) => error | null. Re-asks; stores nothing.
// resolve async (raw) => { value } | { error }. May fail and re-ask —
// geocoding. A value the rest of the flow depends on is never
// stored half-resolved.
// auto async (draft) => { value?, ask?, patch? }. The step answers itself
// from real data and is only ASKED when that fails, with the reason.
// apply (draft, value, option) => draft
//
// A step is skipped when `when` is false OR when `draft[step.id]` is already
// set — which is what lets a caller seed the draft (a client login's tenant) or
// one step fill several fields (picking an existing customer).
const applicable = (step, draft) => (typeof step.when === 'function' ? step.when(draft) : true);
// Finds the next step that applies and hasn't been answered. Async because a
// step may answer itself from the network before we know whether to ask it.
export const advanceFlow = async (steps, flow) => {
let { step, draft } = flow;
while (step < steps.length) {
const s = steps[step];
if (!applicable(s, draft) || draft[s.id] !== undefined) {
step += 1;
// eslint-disable-next-line no-continue
continue;
}
if (s.auto) {
// eslint-disable-next-line no-await-in-loop
const auto = await s.auto(draft);
if (auto?.patch) draft = { ...draft, ...auto.patch };
if (auto?.value !== undefined) {
draft = s.apply(draft, auto.value);
step += 1;
// eslint-disable-next-line no-continue
continue;
}
return { flow: { ...flow, step, draft }, step: s, ask: auto?.ask || s.ask, done: false };
}
return { flow: { ...flow, step, draft }, step: s, done: false };
}
return { flow: { ...flow, step, draft, complete: true }, done: true, draft };
};
export const startFlow = (steps, kind, draft = {}) => advanceFlow(steps, { kind, step: 0, draft });
// Applies one answer — typed text, a chosen dropdown option, or a parsed file.
export const answerFlowStep = async (steps, flow, raw, option) => {
const s = steps[flow.step];
if (!s) return advanceFlow(steps, flow);
if (s.validate) {
const error = s.validate(raw, option);
if (error) return { flow, step: s, ask: error, done: false, retry: true };
}
let value = raw;
if (s.resolve) {
const resolved = await s.resolve(raw);
if (resolved.error) return { flow, step: s, ask: resolved.error, done: false, retry: true };
value = resolved.value;
}
return advanceFlow(steps, { ...flow, step: flow.step + 1, draft: s.apply(flow.draft, value, option) });
};

613
src/lib/assistant/flows.js Normal file
View File

@@ -0,0 +1,613 @@
import dayjs from 'dayjs';
import {
assignMilerToBooking, createExpressBooking, createExpressBookingBulk, createTenantCustomer,
getAdminCustomers, getAdminPricing, getalltenants, getallriders, notifyMiler,
} from '@/api/doormile';
import { calculateDrivingDistance, calculateTotalCharge } from '@/lib/distance';
import { fetchBookingsForDay, scanBookings } from './scan';
import { bestNameMatch, bookingCharge, dayFromWords, formatRupees, isCancelled } from './vocab';
/**
* The assistant's five writes: a customer, a single order, a batch, a rider
* assignment, and a repeated day.
*
* **All five are conversations**, one question per turn — not forms. That is
* explicit product direction, and there is no create-form component here.
*
* **The write gate is non-negotiable.** A flow gathers, then shows exactly what
* will be sent, and the mutation fires only when the operator presses the
* button. `execute` is the only mutating function in this module and nothing
* calls it from a `match`. No intent can trigger a write.
*
* The panel intercepts a reply *before* the router sees it whenever a flow is
* open. That is load-bearing rather than a tidy-up: the router matches text, and
* a bare answer like `9876543210` matches no intent, so without the intercept
* every reply would be lost to "I can't answer that one yet".
*/
/** Customer creation writes to `/admin/tenantcustomers`.
*
* Settled by evidence: `POST /admin/customers` answers 405 Method Not Allowed —
* the route exists and POST is not among its methods. That resource grows a
* customer as a side effect of a booking, which is also why its records carry
* no address. The Customers page reads tenant customers for the same reason, so
* a customer created here appears there immediately.
*
* Creating one *via a booking* was rejected outright: "add a customer" must
* never silently dispatch a delivery.
*/
const CUSTOMER_STEPS = [
{ key: 'firstname', question: 'What is the customer’s first name?', required: true },
{ key: 'lastname', question: 'And their last name? (say "skip" if you don’t have it)' },
{
key: 'phone',
question: 'Their 10-digit mobile number?',
required: true,
validate: (value) => (/^\d{10}$/.test(value) ? null : 'That is not a 10-digit number — try again.'),
},
{ key: 'email', question: 'An email address? (say "skip" if there isn’t one)' },
];
const ORDER_STEPS = [
{ key: 'tenant', question: 'Which client is this order for?', required: true },
{ key: 'pickupaddress', question: 'Where is it collected from?', required: true },
{ key: 'pickupcity', question: 'Which city is the pickup in?', required: true },
{ key: 'pickuppincode', question: 'And the pickup pincode?', required: true },
{ key: 'customer_name', question: 'Who is receiving it?', required: true },
{
key: 'customer_phone',
question: 'Their 10-digit mobile number?',
required: true,
validate: (value) => (/^\d{10}$/.test(value) ? null : 'That is not a 10-digit number — try again.'),
},
{ key: 'deliveryaddress', question: 'What is the delivery address?', required: true },
{ key: 'deliverycity', question: 'Which city is the delivery in?', required: true },
{ key: 'deliverypincode', question: 'And the delivery pincode?', required: true },
{
key: 'finalprice',
question: 'What should this be charged at? (a number, or "skip" to send 0)',
validate: (value) =>
value === '' || !Number.isNaN(Number(value)) ? null : 'That is not a number — try again.',
},
{ key: 'notes', question: 'Anything the rider should know? (or "skip")' },
];
const BULK_STEPS = [
{ key: 'tenant', question: 'Which client are these orders for?', required: true },
{ key: 'pickupaddress', question: 'Where are they all collected from?', required: true },
{ key: 'pickupcity', question: 'Which city is that pickup in?', required: true },
{ key: 'pickuppincode', question: 'And its pincode?', required: true },
{
key: 'rows',
question:
'Now paste the recipients — one per line, as:\n\nname, phone, address, city, pincode\n\nPaste them all in one message.',
required: true,
},
];
/**
* Assign or reassign one order to a rider.
*
* The backend already assigns riders on its own (booking creation publishes an
* assignment-requested event that a worker picks up within minutes) — this
* flow is always an OVERRIDE of a decision the backend may have already made.
* That is why the second step re-states who currently holds the order rather
* than silently overwriting them: an operator who has not been told is far
* more likely to reassign a rider who was already correctly, better-informed,
* on their way.
*
* Unlike the create flows, both steps here need a live lookup (find the order,
* then find the rider) rather than a plain string — see `resolve` below.
*/
const ASSIGN_STEPS = [
{
key: 'orderRef',
question: 'Which order? (the order number, or the DM-… code)',
required: true,
resolve: async (value) => {
const scan = await scanBookings();
const needle = String(value).trim().toLowerCase().replace(/^#/, '');
const booking = scan.rows.find(
(row) =>
String(row.bookingid).toLowerCase() === needle ||
String(row.bookingno || '').toLowerCase() === needle ||
String(row.bookingno || '').toLowerCase().endsWith(needle)
);
if (!booking) return { error: `No order matches "${value}" — try the order number again.` };
return { value: booking };
},
},
{
key: 'riderRef',
/* Names who currently has it, if anyone — the whole point of asking rather
than just overwriting. */
question: (values) => {
const order = values.orderRef;
const label = order?.bookingno || `#${order?.bookingid}`;
return order?.assignedmileruserid
? `${label} is currently assigned. Who should it go to instead?`
: `Who should ${label} go to?`;
},
required: true,
resolve: async (value, values) => {
const riders = (await getallriders()) || [];
const rider = bestNameMatch(value, riders, (r) => r.displayname || r.authname);
if (!rider) return { error: `No rider matches "${value}" — try their name again.` };
if (String(rider.userid) === String(values.orderRef?.assignedmileruserid)) {
return { error: `${rider.displayname || rider.authname} already has this order — name someone else.` };
}
return { value: rider };
},
},
];
/**
* Repeat a previous day's dispatched orders as fresh bookings today.
*
* One question (which day), then a single automated pass — not a run of
* questions — because a booking already carries almost everything
* `createExpressBooking` needs, including both sets of coordinates. Only the
* recipient's name and phone are missing, and those come from the
* `appcustomerid` → `/admin/customers` join, the same one `fetchDeliveries`
* already does.
*
* Duplicate-safety is INVERTED here versus every other flow: near-identical
* orders are the *goal*. The only real risk is running the same day's repeat
* twice, so the guard fingerprints on `(appcustomerid, delivery address,
* pickup pincode)` against TODAY's own bookings, not against the source day.
*
* Prices are re-quoted at today's tariff — `getAdminPricing` + the same
* OSRM-distance-and-tariff formula `CreateOrder.jsx` already uses — never
* copied from the original booking, since a tariff can have changed since.
* Cancelled orders are never repeated, and the lookback is 7 days.
*/
const REPEAT_STEPS = [
{
key: 'sourceDay',
question: 'Which day should I repeat? (say "yesterday", or a date like 2026-08-20)',
required: true,
resolve: async (value) => {
const day = dayFromWords(value);
const today = dayjs().format('YYYY-MM-DD');
if (day > today) return { error: 'That is in the future — nothing to repeat yet.' };
if (day < dayjs().subtract(7, 'day').format('YYYY-MM-DD')) {
return { error: 'That is more than 7 days back — repeat only looks at the last week.' };
}
const [sourceScan, todayScan, customers] = await Promise.all([
fetchBookingsForDay(day),
fetchBookingsForDay(today),
getAdminCustomers().catch(() => []),
]);
const customerMap = new Map((customers || []).map((c) => [c.appcustomerid ?? c.customerid ?? c.id, c]));
const fingerprint = (booking) =>
`${booking.appcustomerid}|${String(booking.deliveryaddress || '').toLowerCase().trim()}|${booking.pickuppincode || ''}`;
const alreadyRepeated = new Set(todayScan.rows.map(fingerprint));
const dispatched = sourceScan.rows.filter((b) => b.assignedmileruserid || b.consignmentid);
const candidates = [];
const skipped = [];
dispatched.forEach((booking) => {
/* Cancelled orders are never repeated — not even reported as skipped,
since there is nothing an operator would do about that reason. */
if (isCancelled(booking)) return;
if (alreadyRepeated.has(fingerprint(booking))) {
skipped.push({ booking, reason: 'already repeated today' });
return;
}
if (!booking.pickupaddress) {
skipped.push({ booking, reason: 'no pickup address recorded on the original order' });
return;
}
if (booking.tenantid == null) {
skipped.push({ booking, reason: 'no client recorded on the original order' });
return;
}
if (!Number.isFinite(Number(booking.deliverylatitude)) || !Number.isFinite(Number(booking.deliverylongitude))) {
skipped.push({ booking, reason: 'delivery address has no saved coordinates' });
return;
}
const customer = customerMap.get(booking.appcustomerid);
const phone = customer?.phone || customer?.contactno;
if (!phone) {
skipped.push({ booking, reason: 'no customer contact number on file' });
return;
}
candidates.push({ booking, customer_name: customer.firstname || customer.name || '', customer_phone: phone });
});
if (!candidates.length) {
return {
error: skipped.length
? `None of the ${skipped.length} dispatched order(s) from ${day} can be repeated — every one is missing something.`
: `No dispatched orders were found on ${day}.`,
};
}
/* Re-quoted per candidate, not fetched once for the day — a repeat can
span more than one client, and each client's own pricing row decides
the number (same reasoning as the bulk-create flow's per-tenant rate
lookup, just per-row here instead of per-file). */
const pricing = await getAdminPricing().catch(() => []);
const priced = await Promise.all(
candidates.map(async (candidate) => {
const rate = pricing.find((row) => String(row.tenantid) === String(candidate.booking.tenantid));
if (!rate) return { ...candidate, finalprice: bookingCharge(candidate.booking), rateNote: 'no pricing configured for this client — used the previous price' };
try {
const km = await calculateDrivingDistance(
{ latitude: candidate.booking.pickuplatitude, longitude: candidate.booking.pickuplongitude },
{ latitude: candidate.booking.deliverylatitude, longitude: candidate.booking.deliverylongitude }
);
const price = calculateTotalCharge(km, Number(rate.baseprice) || 0, Number(rate.priceperkm) || 0, Number(rate.basedistance) || 0);
return { ...candidate, finalprice: Number(price.toFixed(2)) };
} catch {
return { ...candidate, finalprice: bookingCharge(candidate.booking), rateNote: 'could not re-measure the route — used the previous price' };
}
})
);
return { value: { day, candidates: priced, skipped } };
},
},
];
const FLOWS = {
customer: { title: 'New customer', steps: CUSTOMER_STEPS },
order: { title: 'New order', steps: ORDER_STEPS },
bulk: { title: 'Bulk orders', steps: BULK_STEPS },
assign: { title: 'Assign a rider', steps: ASSIGN_STEPS },
repeat: { title: 'Repeat a run', steps: REPEAT_STEPS },
};
/* "assign"/"reassign" + rider word, or "assign order/it/this/DM-…", or "change
the rider" — matched ahead of the create-verb gate below since "assign" is
not one of those verbs. */
const ASSIGN_TRIGGER =
/\b(?:re)?assign\s+(?:a\s+|the\s+|another\s+)?(?:rider|miler|driver)\b|\b(?:re)?assign\s+(?:it\b|this\b|that\b|order\b|DM-[A-Za-z0-9-]+)|\bchange\s+(?:the\s+)?rider\b/i;
/* "repeat yesterday('s run/orders)", "same orders as yesterday", "redo
yesterday" — also matched ahead of the create-verb gate, since "repeat" and
"redo" are not among those verbs either. */
const REPEAT_TRIGGER =
/\brepeat\s+(?:yesterday|today|the\s+run|last\s+\w+|orders?)\b|\bsame\s+orders?\s+as\s+(?:yesterday|last\s+\w+)\b|\bredo\s+(?:yesterday|the\s+run)\b/i;
/** Which flow, if any, a question is asking to start. */
export const detectFlow = (text) => {
const lower = String(text || '').toLowerCase();
if (ASSIGN_TRIGGER.test(lower)) return 'assign';
if (REPEAT_TRIGGER.test(lower)) return 'repeat';
if (!/\b(create|add|new|make|place|raise|book)\b/.test(lower)) return null;
if (/\bcustomer\b/.test(lower)) return 'customer';
if (/\b(bulk|multiple|many|batch of)\b/.test(lower) && /\border/.test(lower)) return 'bulk';
if (/\b(order|booking|delivery)\b/.test(lower)) return 'order';
return null;
};
export const startFlow = (kind) => {
const flow = FLOWS[kind];
if (!flow) return null;
const first = flow.steps[0];
const question = typeof first.question === 'function' ? first.question({}) : first.question;
return { kind, title: flow.title, stepIndex: 0, values: {}, question };
};
/**
* Applies one reply and returns the next state.
*
* Returns `{ flow }` while still gathering, or `{ flow, review }` once every
* step is answered — `review` is the exact payload that will be sent, which the
* operator confirms before anything is written.
*
* Async because a step's `resolve` (assign flow's order/rider lookup) may need
* a live API call; the create flows' steps have no `resolve` and pass through
* exactly as before.
*/
export const advanceFlow = async (flow, reply) => {
const steps = FLOWS[flow.kind].steps;
const step = steps[flow.stepIndex];
const raw = String(reply || '').trim();
const skipped = /^(skip|none|no|n\/a)$/i.test(raw);
const value = skipped ? '' : raw;
if (step.required && !value) {
return { flow, error: 'That one is required — please answer it.' };
}
if (value && step.validate) {
const problem = step.validate(value);
if (problem) return { flow, error: problem };
}
let resolved = value;
if (value && step.resolve) {
const outcome = await step.resolve(value, flow.values);
if (outcome.error) return { flow, error: outcome.error };
resolved = outcome.value;
}
const values = { ...flow.values, [step.key]: resolved };
const nextIndex = flow.stepIndex + 1;
if (nextIndex < steps.length) {
const nextStep = steps[nextIndex];
const question = typeof nextStep.question === 'function' ? nextStep.question(values) : nextStep.question;
return { flow: { ...flow, stepIndex: nextIndex, values, question } };
}
return { flow: { ...flow, stepIndex: nextIndex, values, question: null }, review: buildReview(flow.kind, values) };
};
/** A human-readable summary plus the payload that will actually be sent. */
const buildReview = (kind, values) => {
if (kind === 'repeat') {
const { day, candidates, skipped } = values.sourceDay;
return {
kind,
title: `Repeat ${candidates.length} order${candidates.length === 1 ? '' : 's'} from ${day}?`,
lines: [
['Source day', day],
['Will create', `${candidates.length} order${candidates.length === 1 ? '' : 's'}`],
skipped.length ? ['Skipping', `${skipped.length} — see below`] : null,
].filter(Boolean),
preview: [
...candidates
.slice(0, 5)
.map((c) => `${c.customer_name || 'Customer'} · ${c.booking.deliveryaddress} · ${formatRupees(c.finalprice)}`),
...skipped.slice(0, 3).map((s) => `Skipped ${s.booking.bookingno || `#${s.booking.bookingid}`} — ${s.reason}`),
],
values: { day, candidates, skipped },
};
}
if (kind === 'assign') {
const order = values.orderRef;
const rider = values.riderRef;
return {
kind,
title: 'Assign this order?',
lines: [
['Order', order.bookingno || `#${order.bookingid}`],
['To', rider.displayname || rider.authname],
['Phone', rider.phone],
].filter(([, v]) => v),
values: {
bookingid: order.bookingid,
bookingLabel: order.bookingno || `#${order.bookingid}`,
mileruserid: rider.userid,
milerprofileid: rider.milerprofileid,
riderName: rider.displayname || rider.authname,
},
};
}
if (kind === 'customer') {
return {
kind,
title: 'Create this customer?',
lines: [
['Name', [values.firstname, values.lastname].filter(Boolean).join(' ')],
['Phone', values.phone],
['Email', values.email],
].filter(([, v]) => v),
values,
};
}
if (kind === 'order') {
return {
kind,
title: 'Create this order?',
lines: [
['Client', values.tenant],
['Pickup', `${values.pickupaddress}, ${values.pickupcity} ${values.pickuppincode}`],
['Recipient', `${values.customer_name} · ${values.customer_phone}`],
['Drop', `${values.deliveryaddress}, ${values.deliverycity} ${values.deliverypincode}`],
['Charge', values.finalprice ? formatRupees(Number(values.finalprice)) : formatRupees(0)],
['Notes', values.notes],
].filter(([, v]) => v),
values,
};
}
const parsed = parseBulkRows(values.rows);
return {
kind,
title: `Create ${parsed.length} order${parsed.length === 1 ? '' : 's'}?`,
lines: [
['Client', values.tenant],
['Pickup', `${values.pickupaddress}, ${values.pickupcity} ${values.pickuppincode}`],
['Recipients', `${parsed.length} rows`],
],
preview: parsed.slice(0, 5).map((row) => `${row.customer_name} · ${row.customer_phone} · ${row.deliveryaddress}`),
values: { ...values, parsed },
};
};
/** `name, phone, address, city, pincode` per line. Blank lines are ignored. */
const parseBulkRows = (text) =>
String(text || '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [customer_name, customer_phone, deliveryaddress, deliverycity, deliverypincode] = line
.split(',')
.map((cell) => cell.trim());
return { customer_name, customer_phone, deliveryaddress, deliverycity, deliverypincode };
})
.filter((row) => row.customer_name && row.customer_phone);
const resolveTenantId = async (name) => {
const tenants = (await getalltenants()) || [];
const tenant = bestNameMatch(name, tenants, (t) => t.tenantname);
return tenant?.tenantid ?? null;
};
/**
* The only mutating function in this module. Called from the review card's
* button and from nowhere else.
*/
export const executeFlow = async (review) => {
const { kind, values } = review;
if (kind === 'repeat') {
const bookings = values.candidates.map(({ booking, customer_name, customer_phone, finalprice }) => ({
tenantid: booking.tenantid,
pickupaddress: booking.pickupaddress,
pickupcity: booking.pickupcity || '',
pickuppincode: booking.pickuppincode || '',
pickuplatitude: booking.pickuplatitude,
pickuplongitude: booking.pickuplongitude,
customer_name,
customer_phone,
deliveryaddress: booking.deliveryaddress,
deliverycity: booking.deliverycity || '',
deliverypincode: booking.deliverypincode || '',
deliverylatitude: booking.deliverylatitude,
deliverylongitude: booking.deliverylongitude,
service_option: 'Normal',
finalprice,
notes: booking.notes || '',
parcels: booking.parcels?.length
? booking.parcels.map((p) => ({
itemcategory: p.itemcategory || 'General',
itemdescription: p.itemdescription || 'Order',
declaredvalue: p.declaredvalue || 0,
}))
: [{ itemcategory: 'General', itemdescription: 'Order', declaredvalue: finalprice }],
}));
/* Repeats are scoped to one calendar day, so this should never approach
the bulk endpoint's 200-per-call cap — refused rather than silently
truncated on the rare day that does. */
if (bookings.length > 200) {
return { ok: false, message: `That is ${bookings.length} orders; the bulk endpoint takes 200 at a time.` };
}
const result = await createExpressBookingBulk(bookings);
if (result?.success === false) return { ok: false, message: result.message || 'The orders were not created.' };
/* Two response shapes have been seen from this endpoint in practice — read
defensively rather than assume one. A shape this doesn't recognise just
means the reassignment pass below finds nothing to do; the orders
themselves are still created either way. */
const created = result?.results || result?.data?.results || [];
/* Straight back to whoever had it, one order at a time — sequential on
purpose, since these are real writes against dispatch records and a
burst of concurrent calls makes a partial failure much harder to
attribute to the right order. */
let reassigned = 0;
for (let i = 0; i < values.candidates.length; i += 1) {
const riderId = values.candidates[i].booking.assignedmileruserid;
const createdId = created[i]?.bookingid ?? created[i]?.id;
if (riderId && createdId) {
try {
await assignMilerToBooking(createdId, { mileruserid: Number(riderId) });
reassigned += 1;
} catch {
/* The create already succeeded — a failed reassignment leaves that
order pending rather than undoing it. */
}
}
}
const parts = [
`${bookings.length} order${bookings.length === 1 ? '' : 's'} created from ${values.day}.`,
reassigned ? `${reassigned} went straight back to their previous rider.` : null,
values.skipped.length ? `${values.skipped.length} from the original day could not be repeated.` : null,
].filter(Boolean);
return { ok: true, message: parts.join(' ') };
}
if (kind === 'assign') {
const result = await assignMilerToBooking(values.bookingid, { mileruserid: Number(values.mileruserid) });
if (result?.success === false) return { ok: false, message: result.message || 'The order was not assigned.' };
/* The assignment already landed at this point — a failed push is reported,
not treated as the write having failed. */
let notified = false;
if (values.milerprofileid) {
try {
await notifyMiler(values.milerprofileid, 'DoormileXpress', 'A new order has been assigned to you.');
notified = true;
} catch {
/* swallowed — reported via the message below instead */
}
}
const tail = notified
? ' They have been notified.'
: values.milerprofileid
? ' The notification failed — tell them directly.'
: ' This rider has no profile id, so no notification could be sent.';
return { ok: true, message: `${values.bookingLabel} is now with ${values.riderName}.${tail}` };
}
if (kind === 'customer') {
const result = await createTenantCustomer({
firstname: values.firstname,
lastname: values.lastname || '',
phone: values.phone,
email: values.email || '',
});
if (result?.success === false) return { ok: false, message: result.message || 'The customer was not created.' };
return {
ok: true,
message: `${values.firstname} ${values.lastname || ''}`.trim() + ' was added. They appear on the Customers page now.',
};
}
const tenantid = await resolveTenantId(values.tenant);
if (!tenantid) {
return { ok: false, message: `No client matches "${values.tenant}" — nothing was created.` };
}
if (kind === 'order') {
const price = Number(values.finalprice) || 0;
const result = await createExpressBooking({
tenantid,
pickupaddress: values.pickupaddress,
pickupcity: values.pickupcity,
pickuppincode: values.pickuppincode,
customer_name: values.customer_name,
customer_phone: values.customer_phone,
deliveryaddress: values.deliveryaddress,
deliverycity: values.deliverycity,
deliverypincode: values.deliverypincode,
service_option: 'Normal',
finalprice: price,
notes: values.notes || '',
parcels: [{ itemcategory: 'General', itemdescription: 'Order', declaredvalue: price }],
});
if (result?.success === false) return { ok: false, message: result.message || 'The order was not created.' };
return { ok: true, message: 'Order created. It is on the Orders page under Pending.' };
}
const bookings = (values.parsed || []).map((row) => ({
tenantid,
pickupaddress: values.pickupaddress,
pickupcity: values.pickupcity,
pickuppincode: values.pickuppincode,
customer_name: row.customer_name,
customer_phone: row.customer_phone,
deliveryaddress: row.deliveryaddress || '',
deliverycity: row.deliverycity || '',
deliverypincode: row.deliverypincode || '',
service_option: 'Normal',
finalprice: 0,
notes: '',
parcels: [{ itemcategory: 'General', itemdescription: 'Order', declaredvalue: 0 }],
}));
/* The endpoint takes at most 200 per call — refuse rather than silently
truncating the operator's list. */
if (bookings.length > 200) {
return { ok: false, message: `That is ${bookings.length} rows; this endpoint takes 200 at a time. Split the list.` };
}
const result = await createExpressBookingBulk(bookings);
if (result?.success === false) return { ok: false, message: result.message || 'The orders were not created.' };
return { ok: true, message: `${bookings.length} orders created. They are on the Orders page under Pending.` };
};

2319
src/lib/assistant/intents.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,152 @@
import { createExpressBooking, getTenantLocations } from '@/api/doormile/endpoints';
import { getAdminTenants } from '@/api/doormile/endpoints';
// ==============================|| Doormile AI — create order ||============================== //
//
// Second write capability. Same contract as actions.js: nothing here mutates
// except executeCreateOrder, which the panel calls only when the operator
// presses Create.
//
// Order creation is materially riskier than customer creation — a wrong record
// dispatches a real rider.
//
// ⚠ `pickuplocationid` DOES NOT WORK. express-console-api.md documents it as
// the way to reference a stored pickup site, and this file was originally
// built on it, but createorder1.js records (in two places) that the field
// live-500s on POST /admin/expressbooking — a reported backend bug, with no
// frontend workaround other than not using it. The page therefore always
// sends the RAW pickup fields, copying them out of the chosen saved location.
//
// This does the same. The operator still picks a saved location — that part
// is good UX and keeps CityGate satisfied, since a stored site has already
// passed it — but what goes on the wire is pickupaddress / pickuppincode /
// pickupcity / pickuplatitude / pickuplongitude, exactly as the page sends.
export const CREATE_ORDER_TRIGGER = /\b(?:create|add|place|book|new)\s+(?:a\s+|an\s+|the\s+)?(?:new\s+)?(?:order|booking|delivery)\b/i;
// Doormile's own service tiers (express-console-api.md).
export const SERVICE_OPTIONS = ['Normal', 'Fast', 'Superfast'];
// CityGate is enforced server-side, before the handler runs. Checking it here
// too means the operator is told which cities are open BEFORE submitting,
// instead of getting an opaque middleware rejection back.
export const OPEN_CITY_PREFIXES = {
641: 'Coimbatore',
600: 'Chennai',
560: 'Bengaluru',
500: 'Hyderabad',
629: 'Nagercoil'
};
export const cityGateFor = (pincode) => {
const prefix = String(pincode || '').slice(0, 3);
return OPEN_CITY_PREFIXES[prefix] || null;
};
export const loadOrderTenants = async () => (await getAdminTenants()) || [];
// Saved pickup sites for a tenant. Each carries address / city / pincode /
// coordinates — which is what the payload copies onto the booking, since
// `pickuplocationid` itself 500s (see the note at the top of this file).
export const loadPickupLocations = async (tenantid) => {
if (!tenantid) return [];
return (await getTenantLocations(tenantid)) || [];
};
const PHONE_RE = /^\d{10}$/;
// Mirrors createorder1.js's own gate, plus the two rules the endpoint enforces
// that the page leaves to the server (non-empty parcels, CityGate).
export const validateOrderDraft = (d) => {
const errors = {};
if (!d.tenantid) errors.tenantid = 'Choose a tenant';
// Still required: the operator picks a saved site so its address/pincode/
// coordinates can be copied onto the booking. The id itself is never sent.
if (!d.pickuplocationid) errors.pickuplocationid = 'Choose a pickup location';
if (!d.customer_name?.trim()) errors.customer_name = 'Required';
if (!PHONE_RE.test(String(d.customer_phone || '').trim())) errors.customer_phone = 'Enter exactly 10 digits';
if (!d.deliveryaddress?.trim()) errors.deliveryaddress = 'Required';
if (!String(d.deliverypincode || '').trim()) errors.deliverypincode = 'Required';
if (!d.itemdescription?.trim()) errors.itemdescription = 'Describe what is being sent';
if (d.finalprice === '' || d.finalprice == null || Number.isNaN(Number(d.finalprice))) errors.finalprice = 'Enter an amount';
// Delivery coordinates come from the address search. Without them the
// optimiser has nothing to route against, so refuse rather than send a
// booking that can never be dispatched.
if (!Number.isFinite(Number(d.deliverylatitude)) || !Number.isFinite(Number(d.deliverylongitude))) {
errors.deliveryaddress = 'Pick the address from the suggestions so coordinates are captured';
}
return { ok: Object.keys(errors).length === 0, errors };
};
// The exact body that will be POSTed. Mirrors createorder1.js's own payload —
// raw pickup fields, never `pickuplocationid` (see the note at the top).
//
// `pickup` is the saved location record the operator chose; its address,
// pincode, city and coordinates are copied onto the booking.
export const buildOrderPayload = (d, pickup) => ({
tenantid: Number(d.tenantid),
pickupaddress: pickup?.address || '',
pickuppincode: String(pickup?.pincode || ''),
pickupcity: pickup?.city || '',
pickuplatitude: Number(pickup?.latitude) || 0,
pickuplongitude: Number(pickup?.longitude) || 0,
customer_name: d.customer_name.trim(),
customer_phone: String(d.customer_phone).trim(),
deliveryaddress: d.deliveryaddress.trim(),
deliverypincode: String(d.deliverypincode).trim(),
deliverycity: d.deliverycity || '',
deliverylatitude: Number(d.deliverylatitude),
deliverylongitude: Number(d.deliverylongitude),
service_option: SERVICE_OPTIONS.includes(d.service_option) ? d.service_option : 'Normal',
finalprice: Number(d.finalprice),
notes: d.notes || '',
// A parcel entry has no quantity field — N items means N entries, which is
// how the Deliveries page reads it back (`Quantity: b.parcels?.length`).
parcels: Array.from({ length: Math.max(1, Number(d.quantity) || 1) }, () => ({
itemcategory: d.itemcategory || 'General',
itemdescription: d.itemdescription.trim(),
declaredvalue: Number(d.declaredvalue) || 0
}))
});
// The only order-writing function in the assistant.
export const executeCreateOrder = async (payload) => {
const started = Date.now();
const call = {
name: 'createExpressBooking',
target: 'POST /admin/expressbooking',
stats: `tenant ${payload.tenantid}, ${payload.parcels.length} parcel${payload.parcels.length === 1 ? '' : 's'}`
};
try {
const res = await createExpressBooking(payload);
const duration = `${Date.now() - started}ms`;
if (res && res.success === false) {
return {
ok: false,
message: res.message || 'The server rejected the order.',
sourceCalls: [{ ...call, duration, status: 'error', errorMessage: res.message }]
};
}
const created = res?.data || res;
return {
ok: true,
id: created?.bookingid ?? created?.id,
bookingno: created?.bookingno,
created,
sourceCalls: [{ ...call, duration, status: 'complete', stats: `created ${created?.bookingno || created?.bookingid || '—'}` }]
};
} catch (err) {
const duration = `${Date.now() - started}ms`;
const status = err.httpStatus ?? err.response?.status;
const serverMessage = err.message || err.error;
const message =
status === 404 || status === 405
? `POST /admin/expressbooking returned ${status} — that route does not exist on the server.`
: status
? `POST /admin/expressbooking returned ${status}${serverMessage ? ` — ${serverMessage}` : ''}. Nothing was saved.`
: `${serverMessage || 'The request failed'} — nothing was saved.`;
return { ok: false, message, sourceCalls: [{ ...call, duration, status: 'error', errorMessage: message }] };
}
};

View File

@@ -0,0 +1,268 @@
import { getAdminPricing, getAdminCustomers, getTenantLocations } from '@/api/doormile/endpoints';
import { getAdminTenants } from '@/api/doormile/endpoints';
import { geocodeAddress } from '@/components/doormile/AddressAutocomplete';
import { calculateDrivingDistance, calculateTotalCharge, getLastRouteDurationMin } from '@/lib/distance';
import { SERVICE_OPTIONS, cityGateFor } from './orderActions';
import { advanceFlow, startFlow, answerFlowStep } from './flowEngine';
// ==============================|| Doormile AI — conversational create-order ||============================== //
//
// One question at a time, mirroring createorder1.js's own field set and using a
// DROPDOWN wherever that page uses one — business location, customer, category,
// weight, service tier. Free text is only for things that genuinely are free
// text (a name, an address, a description).
//
// Two capabilities the customer flow didn't need:
//
// • BRANCHING. "Existing customer or new?" splits the path: existing skips
// straight to picking from the real customer list, new asks for the fields.
// Steps carry a `when` predicate and are skipped when it's false.
// • ASYNC OPTIONS. Locations, customers and tenants are fetched live, so a
// dropdown never shows a stale or invented list.
//
// The conversation is driven by the PANEL, which intercepts replies before the
// router ever sees them — see customerFlow.js for why that matters (a bare
// "8494948494" matches no intent and used to be discarded).
const PHONE_RE = /^\d{10}$/;
const digits = (t) => String(t || '').replace(/\D/g, '');
const isStaffLogin = () => {
const t = localStorage.getItem('tenantid');
return !t || t === '0';
};
// ---- steps ------------------------------------------------------------------
//
// type: 'select' → the panel renders a dropdown from `options(draft)`
// 'text' → answered through the composer
// when: omitted means always asked
export const ORDER_STEPS = [
{
id: 'tenantid',
type: 'select',
ask: 'Which tenant is this order for?',
// A client login already has its tenant; only Doormile staff choose.
when: () => isStaffLogin(),
options: async () => {
const tenants = (await getalltenants()) || [];
return tenants.map((t) => ({ value: String(t.tenantid), label: t.tenantname || `Tenant #${t.tenantid}` }));
},
apply: (d, v) => ({ ...d, tenantid: v })
},
{
id: 'pickuplocationid',
type: 'select',
ask: 'Which business location is this picked up from?',
options: async (d) => {
const tid = d.tenantid || localStorage.getItem('tenantid');
const locations = (await getTenantLocations(tid)) || [];
return locations.map((l) => ({
value: String(l.locationid),
// The pincode is shown because it decides CityGate — a location outside
// the open cities will be refused server-side, and the operator should
// see that before choosing rather than after submitting.
label: `${l.locationname || l.address || 'Location'}${
l.pincode ? ` · ${l.pincode}${cityGateFor(l.pincode) ? '' : ' (closed city)'}` : ''
}`,
record: l
}));
},
validate: (v, option) =>
cityGateFor(option?.record?.pincode)
? null
: `That location’s pincode (${
option?.record?.pincode || 'unknown'
}) is outside the cities Doormile serves, so the server would refuse the booking. Pick another location.`,
apply: (d, v, option) => ({ ...d, pickuplocationid: v, __pickup: option?.record })
},
{
id: 'customerMode',
type: 'select',
ask: 'Is this an existing customer, or a new one?',
options: async () => [
{ value: 'existing', label: 'Existing customer' },
{ value: 'new', label: 'New customer' }
],
apply: (d, v) => ({ ...d, customerMode: v })
},
{
id: 'existingCustomer',
type: 'select',
ask: 'Which customer?',
when: (d) => d.customerMode === 'existing',
options: async () => {
const customers = (await getAdminCustomers()) || [];
return customers
.filter((c) => c.phone)
.map((c) => ({
value: String(c.appcustomerid ?? c.id),
label: `${c.name || [c.firstname, c.lastname].filter(Boolean).join(' ') || 'Customer'} · ${c.phone}`,
record: c
}));
},
// Picking an existing customer fills the name and phone, so the two
// free-text steps below are skipped by their own `when`.
apply: (d, v, option) => ({
...d,
customer_name: option?.record?.name || [option?.record?.firstname, option?.record?.lastname].filter(Boolean).join(' '),
customer_phone: digits(option?.record?.phone)
})
},
{
id: 'customer_name',
type: 'text',
ask: 'What’s the customer’s name?',
when: (d) => d.customerMode === 'new' && !d.customer_name,
validate: (t) => (String(t).trim().length >= 2 ? null : 'I need a name — at least two characters.'),
apply: (d, t) => ({ ...d, customer_name: String(t).trim() })
},
{
id: 'customer_phone',
type: 'text',
ask: 'And their 10-digit mobile number?',
when: (d) => !d.customer_phone,
validate: (t) => (PHONE_RE.test(digits(t).replace(/^91(?=\d{10}$)/, '')) ? null : 'That doesn’t look like 10 digits — try again.'),
apply: (d, t) => ({ ...d, customer_phone: digits(t).replace(/^91(?=\d{10}$)/, '') })
},
{
id: 'deliveryaddress',
type: 'text',
ask: 'Where is it being delivered? Give the full address.',
// Geocoded on the way in: the dispatch optimiser routes on coordinates, so
// an address that can't be located is refused here rather than becoming a
// booking nothing can dispatch.
resolve: async (t) => {
const place = await geocodeAddress(String(t).trim()).catch(() => null);
if (!place) return { error: 'I couldn’t find that address. Try adding the area or pincode.' };
const parts = { deliveryaddress: place.formatted_address || String(t).trim() };
(place.address_components || []).forEach((c) => {
if ((c.types || []).includes('locality')) parts.deliverycity = c.long_name;
if ((c.types || []).includes('postal_code')) parts.deliverypincode = c.long_name;
});
return {
value: {
...parts,
deliverylatitude: place.geometry?.location?.lat?.(),
deliverylongitude: place.geometry?.location?.lng?.()
}
};
},
apply: (d, v) => ({ ...d, ...v })
},
{
id: 'deliverypincode',
type: 'text',
ask: 'What’s the delivery pincode?',
when: (d) => !d.deliverypincode,
validate: (t) => (digits(t).length >= 5 ? null : 'A pincode should be at least 5 digits.'),
apply: (d, t) => ({ ...d, deliverypincode: digits(t) })
},
{
id: 'service_option',
type: 'select',
ask: 'Which service level?',
options: async () => SERVICE_OPTIONS.map((o) => ({ value: o, label: o })),
apply: (d, v) => ({ ...d, service_option: v })
},
{
id: 'itemcategory',
type: 'select',
ask: 'What kind of parcel is it?',
options: async () => PARCEL_CATEGORIES.map((c) => ({ value: c, label: c })),
apply: (d, v) => ({ ...d, itemcategory: v })
},
{
id: 'weight',
type: 'select',
ask: 'Roughly how heavy?',
options: async () => WEIGHT_OPTIONS.map((w) => ({ value: w, label: w })),
apply: (d, v) => ({ ...d, weight: v })
},
{
id: 'itemdescription',
type: 'text',
ask: 'Briefly, what’s inside?',
validate: (t) => (String(t).trim().length >= 2 ? null : 'A short description, please.'),
apply: (d, t) => ({ ...d, itemdescription: String(t).trim() })
},
{
id: 'quantity',
type: 'select',
ask: 'How many parcels?',
options: async () => [1, 2, 3, 4, 5].map((n) => ({ value: String(n), label: String(n) })),
apply: (d, v) => ({ ...d, quantity: Number(v) || 1 })
},
{
id: 'finalprice',
type: 'text',
ask: 'What should the price be? Enter the amount in ₹.',
// `auto` answers a step from real data and only falls back to asking. The
// quote is stashed either way so the confirmation can show the distance it
// measured, and say why it couldn’t price when it couldn’t.
auto: async (d) => {
const quote = await priceOrder(d);
if (quote.total != null) return { patch: { __quote: quote }, value: quote.total };
return {
patch: { __quote: quote },
ask: `I couldn’t price this automatically — ${quote.error}. What should the price be? Enter the amount in ₹.`
};
},
validate: (t) => (Number(t) > 0 ? null : 'Give me an amount greater than zero.'),
apply: (d, t) => ({ ...d, finalprice: Number(t) })
}
];
// Mirrors createorder1.js's own lists so the bot offers the same choices.
const PARCEL_CATEGORIES = ['Food', 'Groceries', 'Documents', 'Electronics', 'Clothing & Apparel', 'Medicines', 'Furniture', 'Others'];
const WEIGHT_OPTIONS = ['1-10kgs', '11-20kgs', '21-30kgs'];
// ---- pricing ----------------------------------------------------------------
//
// Same formula the page uses: basePrice + (distance − minKm) × pricePerKm, from
// this tenant's own pricing row. Quoted, never invented — if no pricing row
// matches, the operator is asked for the amount rather than shown a zero.
export const priceOrder = async (draft) => {
const tid = draft.tenantid || localStorage.getItem('tenantid');
const pricing = (await getAdminPricing()) || [];
// tenantid is numeric on the pricing row and a string from localStorage — a
// strict comparison here silently priced every order at zero once before.
const match = pricing.find((p) => String(p.tenantid) === String(tid));
const pickup = draft.__pickup;
if (!pickup || !Number.isFinite(Number(draft.deliverylatitude))) return { error: 'missing coordinates' };
const km = await calculateDrivingDistance(
{ latitude: pickup.latitude, longitude: pickup.longitude },
{ latitude: draft.deliverylatitude, longitude: draft.deliverylongitude }
).catch(() => null);
if (km == null) return { error: 'could not measure the distance' };
if (!match) return { km, durationMin: getLastRouteDurationMin(), error: 'no pricing configured for this tenant' };
const total = calculateTotalCharge(km, match.baseprice, match.priceperkm, match.basedistance);
return {
km,
durationMin: getLastRouteDurationMin(),
basePrice: match.baseprice,
total: Number(Number(total).toFixed(2))
};
};
// ---- engine -----------------------------------------------------------------
//
// The walker itself lives in flowEngine.js — bulkFlow.js drives the same one.
// These wrappers keep the order-specific names the panel and the tests use.
export const advanceOrder = (flow) => advanceFlow(ORDER_STEPS, flow);
export const startOrderFlow = () => {
// A client login already belongs to a tenant, so its step is skipped — but
// the payload still needs the id, and `Number(undefined)` is NaN. Seeding the
// draft is what makes the skip safe.
const tid = localStorage.getItem('tenantid');
return startFlow(ORDER_STEPS, 'createOrder', tid && tid !== '0' ? { tenantid: tid } : {});
};
export const answerOrderStep = (flow, raw, option) => answerFlowStep(ORDER_STEPS, flow, raw, option);

View File

@@ -0,0 +1,103 @@
/**
* What the assistant knows about where the operator is standing.
*
* Every page offers every question. The assistant answers about orders, riders,
* hubs and the rest regardless of which screen is open, so hiding a question
* because you happen to be on Dispatch made it look narrower than it is. The
* page decides the ORDER — its own questions lead — not the membership.
*
* Every suggestion here must actually resolve against the intent catalog. A
* chip that comes back "I can't answer that yet" is worse than no chip.
*/
const CATALOG = [
'How many orders today?',
'How many orders are cancelled?',
'Morning batch orders',
'Revenue this week',
'How many riders are active?',
'How many clients do we have?',
'How many hubs?',
'How many vehicles?',
'Any open exceptions?',
'How many tripsheets are dispatched?',
'How many customers?',
'How many pricing rules?',
'Track consignment DM-CN-1',
'Assign a rider to an order',
"Repeat yesterday's run",
'Compare orders today vs yesterday',
'Status of hub Chennai',
'Find vehicle TN01AB1234',
'How many competitor branches are tracked?',
'How many carrier pricing entries do we have?',
];
const BY_ROUTE = [
[
/^\/doormile\/dispatch/,
'Dispatch',
['Morning batch orders', 'How many riders are active?', "Repeat yesterday's run", 'How many orders today?'],
],
[
/^\/doormile\/orders/,
'Orders',
['How many orders today?', 'Assign a rider to an order', 'How many orders are cancelled?', 'Revenue this week'],
],
[/^\/doormile\/deliveries/, 'Deliveries', ['How many orders are delivered?', 'Track consignment DM-CN-1', 'Morning batch orders']],
[/^\/doormile\/riders/, 'Riders', ['How many riders are active?', 'Where is Murali?']],
[/^\/doormile\/tenants/, 'Clients', ['How many clients do we have?', 'How many pricing rules?']],
[/^\/doormile\/customers/, 'Customers', ['How many customers?', 'Create a customer']],
[/^\/doormile\/pricing/, 'Pricing', ['How many pricing rules?', 'Revenue this week']],
[/^\/doormile\/hubs/, 'Hubs', ['How many hubs?', 'Status of hub Chennai', 'How many vehicles?']],
[/^\/doormile\/vehicles/, 'Vehicles', ['How many vehicles?', 'Find vehicle TN01AB1234', 'How many hubs?']],
[/^\/doormile\/tripsheets/, 'Tripsheets', ['How many tripsheets are dispatched?', 'How many consignments?']],
[/^\/doormile\/exceptions/, 'Exceptions', ['Any open exceptions?', 'How many consignments?']],
[
/^\/doormile\/competitive-intel/,
'Competitive Intel',
['How many competitor branches are tracked?', 'How many carrier pricing entries do we have?'],
],
[/^\/doormile\/reports/, 'Reports', ['Revenue this week', 'Compare orders today vs yesterday', 'How many orders today?']],
];
/** `{ label, suggestions }` for a route — its own questions first, then the rest. */
export const getPageContext = (pathname) => {
const hit = BY_ROUTE.find(([pattern]) => pattern.test(pathname || ''));
const label = hit ? hit[1] : 'Console';
const leading = hit ? hit[2] : [];
/* Deduplicated, leading questions first — one flat list means one place a
question can live. */
const suggestions = [...new Set([...leading, ...CATALOG])];
return { label, suggestions };
};
export const CHIP_LABELS = {
'How many orders today?': 'Orders today',
'How many orders are cancelled?': 'Cancelled',
'Morning batch orders': 'Morning batch',
'Revenue this week': 'Revenue',
'How many riders are active?': 'Active riders',
'How many clients do we have?': 'Clients',
'How many hubs?': 'Hubs',
'How many vehicles?': 'Vehicles',
'Any open exceptions?': 'Exceptions',
'How many tripsheets are dispatched?': 'Tripsheets',
'How many customers?': 'Customers',
'How many pricing rules?': 'Pricing rules',
'How many orders are delivered?': 'Delivered',
'Where is Murali?': 'Locate Murali',
'Create a customer': 'New customer',
'How many consignments?': 'Consignments',
'Track consignment DM-CN-1': 'Track DM-CN-1',
'Assign a rider to an order': 'Assign rider',
'Repeat yesterday\'s run': 'Repeat run',
'Compare orders today vs yesterday': 'Compare orders',
'Status of hub Chennai': 'Chennai hub',
'Find vehicle TN01AB1234': 'Find vehicle',
'How many competitor branches are tracked?': 'Competitor branches',
'How many carrier pricing entries do we have?': 'Carrier pricing'
};
export const ORDER_CREATED = '__orderCreated';
export const ORDER_CREATED_ASSIGNED = '__orderCreatedAssigned';

View File

@@ -0,0 +1,66 @@
// ==============================|| Doormile AI — semantic routing client ||============================== //
//
// Talks to the retrieval sidecar (services/ai) to decide WHICH QUESTION was
// asked. It never returns data — every figure still comes from the intent's own
// deterministic run(), through the same typed API functions the pages use.
//
// The whole module is optional by design:
//
// • REACT_APP_AI_URL unset → disabled, regex matcher only (production today)
// • sidecar unreachable → disabled for this call, regex matcher
// • slow → aborted at ROUTE_TIMEOUT_MS, regex matcher
// • low confidence → not used, regex matcher
//
// Today's behaviour is the floor. This can raise it, never lower it.
const BASE = import.meta.env.VITE_AI_URL || import.meta.env.REACT_APP_AI_URL || '';
const ROUTE_TIMEOUT_MS = 400;
export const isRagEnabled = () => Boolean(BASE);
// Once the sidecar has failed we stop hammering it on every keystroke-fast
// question. Re-armed after a cool-off so a restarted container is picked up
// without a page reload.
let disabledUntil = 0;
const COOL_OFF_MS = 30000;
const post = async (path, body) => {
if (!BASE || Date.now() < disabledUntil) return null;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ROUTE_TIMEOUT_MS);
try {
const res = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal
});
if (!res.ok) throw new Error(`${res.status}`);
return await res.json();
} catch {
// Any failure — offline, timeout, 5xx — is silent by design. The operator
// gets the deterministic answer; they should never see plumbing.
disabledUntil = Date.now() + COOL_OFF_MS;
return null;
} finally {
clearTimeout(timer);
}
};
// Returns { intentId, confidence, score, margin, alternatives } or null.
export const routeQuestion = (text) => post('/route', { text });
// Documentation passages, verbatim with attribution. No generation step —
// summarising would need a hosted model (assistant/CLAUDE.md §2) and would let
// a paraphrase drift from what the doc actually says.
export const askDocs = (text) => post('/ask', { text });
// A semantic near-miss must never open a create form. Write intents require
// high confidence AND corroboration from the deterministic trigger, so the
// worst case is that the operator types the phrase the regex already knows.
export const isRouteTrustworthy = (routed) => {
if (!routed?.intentId) return false;
if (routed.isWrite) return routed.confidence === 'high';
return routed.confidence === 'high' || routed.confidence === 'medium';
};

View File

@@ -0,0 +1,30 @@
import { findRecentRuns, describeDay } from './repeatRuns';
import { startFlow, advanceFlow, answerFlowStep } from './flowEngine';
// ==============================|| Doormile AI — repeat a run ||============================== //
//
// One question: which day. Everything after it — resolving customers, the drift
// check, re-quoting at today's tariff, the already-repeated guard — is a pass
// the panel runs and narrates, exactly like the bulk file's locate/price phase.
// None of it is a question, so none of it is a step.
export const REPEAT_STEPS = [
{
id: 'day',
type: 'select',
ask: 'Which day’s orders should I repeat?',
options: async () => {
const { runs } = await findRecentRuns();
return runs.map((r) => ({
value: r.day,
label: `${describeDay(r.day)} — ${r.count} order${r.count === 1 ? '' : 's'}`,
record: r
}));
},
apply: (d, v, option) => ({ ...d, day: v, sourceCount: option?.record?.count })
}
];
export const startRepeatFlow = () => startFlow(REPEAT_STEPS, 'repeatRun', {});
export const advanceRepeat = (flow) => advanceFlow(REPEAT_STEPS, flow);
export const answerRepeatStep = (flow, raw, option) => answerFlowStep(REPEAT_STEPS, flow, raw, option);

View File

@@ -0,0 +1,244 @@
import dayjs from 'dayjs';
import { getAdminCustomers } from '@/api/doormile/endpoints';
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
import { groupForBookingStatus } from '@/lib/orderStatusGroups';
import { scanBookings } from './intents';
import { cityGateFor } from './orderActions';
import { priceBulkRows } from './bulkOrderActions';
// ==============================|| Doormile AI — repeat a past day's run ||============================== //
//
// "I created ten orders yesterday; make the same ten today."
//
// This is the cheapest write in the assistant, and the reason is worth stating:
// a booking already carries 15 of the 17 fields `buildOrderPayload` needs —
// both addresses, both pincodes, BOTH SETS OF COORDINATES, the service tier,
// the parcels. Only the recipient's name and phone are missing, and those come
// from the `appcustomerid` → /admin/customers join the assistant already does.
//
// So a repeat needs NO geocoding. The ~1 lookup/second Nominatim throttle that
// dominates the bulk-file flow does not apply here at all.
//
// ---- A booking is a snapshot, not a template --------------------------------
//
// Which is why every row goes through a drift check before it can be repeated.
// These are not hypothetical; all three were found in one live page of 36
// bookings:
//
// • `pickupaddress` can be ABSENT entirely (booking 57 carries the pincode
// and coordinates but no address key at all) — repeating it blind sends an
// empty pickup address.
// • `tenantid` can be null (every `Customer_App` booking) — `Number(null)`
// is 0, so the payload would claim tenant zero.
// • a pickup pincode that was open when the order was placed may not be now,
// and CityGate refuses at the middleware, before the handler runs.
//
// ---- Duplicate safety is INVERTED here --------------------------------------
//
// Everywhere else in this assistant, near-identical orders are an error to
// prevent (`wasAlreadySubmitted`). A repeat run deliberately creates them, so
// that guard would misfire on every single run. The question that actually
// matters is different: *has this run already been repeated today?* The
// endpoint has no idempotency key, so it is answered by looking: today's own
// bookings are fingerprinted and any row already present is set aside rather
// than booked twice.
export const REPEAT_TRIGGER = /\b(?:repeat|redo|re-?run)\b|\bsame\s+orders?\s+as\b|\bsame\s+as\s+(?:yesterday|last)\b/i;
// How far back a run can be recalled from. Beyond a week it stops being "the
// usual round" and starts being archaeology.
const LOOKBACK_DAYS = 7;
const dayOf = (value) => {
const t = parseDoormileTimestamp(value);
return t.isValid() ? t.format('YYYY-MM-DD') : null;
};
// A row is only worth repeating if it was a real order. Cancelled ones are
// excluded — repeating a cancellation is never what "same as yesterday" means.
const isRepeatable = (b) => groupForBookingStatus(b.status) !== 'cancelled';
// ---- which days have a run to repeat ---------------------------------------
export const findRecentRuns = async () => {
const scan = await scanBookings();
const today = dayjs().format('YYYY-MM-DD');
const counts = new Map();
(scan.rows || []).forEach((b) => {
if (!isRepeatable(b)) return;
const day = dayOf(b.createdat);
if (!day || day === today) return;
if (dayjs(today).diff(dayjs(day), 'day') > LOOKBACK_DAYS) return;
counts.set(day, (counts.get(day) || 0) + 1);
});
return {
scan,
runs: [...counts.entries()].map(([day, count]) => ({ day, count })).sort((a, b) => (a.day < b.day ? 1 : -1))
};
};
export const describeDay = (day) => {
const yesterday = dayjs().subtract(1, 'day').format('YYYY-MM-DD');
if (day === yesterday) return 'Yesterday';
return dayjs(day).format('ddd D MMM');
};
// ---- one booking → one repeatable row ---------------------------------------
//
// `__pickup` travels on the ROW, not on the shared draft: a day's run can span
// several kitchens and tenants, and collapsing them onto one shared pickup
// would silently re-address half the orders.
const toRow = (booking, customer, index) => ({
line: index + 1,
source: booking.bookingno || `#${booking.bookingid}`,
tenantid: booking.tenantid,
customer_name: customer?.name || [customer?.firstname, customer?.lastname].filter(Boolean).join(' ') || '',
customer_phone: String(customer?.phone || customer?.contactno || '').replace(/\D/g, ''),
deliveryaddress: booking.deliveryaddress || '',
deliverypincode: String(booking.deliverypincode || ''),
deliverycity: booking.deliverycity || '',
deliverylatitude: booking.deliverylatitude,
deliverylongitude: booking.deliverylongitude,
service_option: booking.serviceoptions?.[0]?.servicetype || 'Normal',
// Deliberately blank: the chosen behaviour is to re-quote at today's tariff,
// so this is left for priceBulkRows to fill exactly as an unpriced bulk row
// would be. Copying yesterday's number would silently bill an old tariff.
finalprice: '',
previousPrice: booking.serviceoptions?.[0]?.estimatedprice,
itemcategory: booking.parcels?.[0]?.itemcategory || 'General',
itemdescription: booking.parcels?.[0]?.itemdescription || 'Order',
quantity: Math.max(1, booking.parcels?.length || 1),
notes: booking.notes || '',
// Who ran this drop last time. Carried so the repeat can hand the new order
// back to the same rider — they already know the door, the buzzer and the
// customer. Null when yesterday's order was never assigned, which is a
// normal case and simply means the copy stays pending.
__previousMilerUserId: booking.assignedmileruserid ?? null,
__pickup: {
address: booking.pickupaddress,
pincode: booking.pickuppincode,
city: booking.pickupcity,
latitude: booking.pickuplatitude,
longitude: booking.pickuplongitude
}
});
// ---- the drift check --------------------------------------------------------
//
// Returns a REASON, never a boolean — an operator dropping a row deserves to
// know which field went stale.
const driftReason = (row) => {
if (!row.tenantid) return 'the original had no tenant, so this would be booked against tenant 0';
if (!row.__pickup?.address) return 'the original booking carries no pickup address';
if (!row.customer_phone) return 'the customer record is gone, so there is no phone number';
if (!row.customer_name) return 'the customer record is gone, so there is no name';
if (!row.deliveryaddress) return 'no delivery address on the original';
if (!Number.isFinite(Number(row.deliverylatitude)) || !Number.isFinite(Number(row.deliverylongitude))) {
return 'the original has no delivery coordinates, so it could never be routed';
}
if (!cityGateFor(row.__pickup.pincode)) {
return `its pickup pincode (${row.__pickup.pincode || 'unknown'}) is no longer a city Doormile serves`;
}
return null;
};
// What makes two orders "the same order". It has to include the PARCEL, not
// just the destination: a run to one address for one customer is a completely
// normal thing to do twice in a day with different contents, and judging on
// phone + address + pickup alone made three unrelated bookings to the same bus
// stand look identical — the repeat then reported all of yesterday as already
// created when only one unrelated order existed today.
//
// Every field here is one a repeat reproduces EXACTLY, which is what makes it a
// usable identity. Price is deliberately excluded: it is re-quoted at today's
// tariff, so it differs by design on every legitimate repeat.
const fingerprint = (row) =>
[
row.customer_phone,
String(row.deliveryaddress || '').toLowerCase(),
row.__pickup?.pincode || '',
row.service_option,
row.itemcategory,
row.itemdescription,
row.quantity
].join('|');
// ---- assemble the run -------------------------------------------------------
export const buildRepeatRun = async (day, { onProgress, shouldStop } = {}) => {
const [{ scan }, customers] = await Promise.all([findRecentRuns(), getAdminCustomers().catch(() => [])]);
const customerMap = new Map((customers || []).map((c) => [c.appcustomerid ?? c.id, c]));
const source = (scan.rows || []).filter((b) => isRepeatable(b) && dayOf(b.createdat) === day);
const rows = source.map((b, i) => toRow(b, customerMap.get(b.appcustomerid), i));
// Already repeated today? Today's own bookings are put through the SAME
// `toRow` shaping, so both sides of the comparison get identical defaults —
// hand-rolling the today side is how the two drifted apart in the first place.
//
// Counts, not a Set. A Set answers "does anything today look like this", so a
// single matching order suppressed EVERY row that shared its fingerprint —
// one order created today wiped out all of yesterday's. A multiset answers
// the question that was actually meant: how many of these already exist. Two
// identical orders yesterday and one today means one still needs creating.
const today = dayjs().format('YYYY-MM-DD');
const todayCounts = new Map();
(scan.rows || [])
.filter((b) => isRepeatable(b) && dayOf(b.createdat) === today)
.forEach((b) => {
const key = fingerprint(toRow(b, customerMap.get(b.appcustomerid), 0));
todayCounts.set(key, (todayCounts.get(key) || 0) + 1);
});
const drifted = [];
const already = [];
const candidates = [];
rows.forEach((row) => {
const reason = driftReason(row);
if (reason) {
drifted.push({ ...row, error: reason });
return;
}
// Consume one match per already-existing order, so a second identical row
// is still offered once the first has been accounted for.
const key = fingerprint(row);
const remaining = todayCounts.get(key) || 0;
if (remaining > 0) {
todayCounts.set(key, remaining - 1);
already.push(row);
return;
}
candidates.push(row);
});
// Re-quote at today's tariff. Priced per row because a run can span tenants,
// and a tenant's own pricing row is what decides the number.
const priced = [];
for (let i = 0; i < candidates.length; i += 1) {
if (shouldStop?.()) break;
const row = candidates[i];
onProgress?.({ phase: 'price', done: i, total: candidates.length, current: row.customer_name });
// eslint-disable-next-line no-await-in-loop
const [out] = await priceBulkRows([row], row.__pickup, row.tenantid);
priced.push(out);
}
if (priced.length < candidates.length) priced.push(...candidates.slice(priced.length));
const valid = priced.filter((r) => !r.priceError && Number(r.finalprice) > 0);
const unpriced = priced.filter((r) => r.priceError || !(Number(r.finalprice) > 0));
return {
day,
scan,
total: rows.length,
valid,
drifted,
already,
unpriced,
// Every price that moved since the original, so a tariff change is visible
// rather than discovered on an invoice.
changed: valid.filter((r) => r.previousPrice != null && Number(r.previousPrice) !== Number(r.finalprice))
};
};

145
src/lib/assistant/scan.js Normal file
View File

@@ -0,0 +1,145 @@
import { getBookingsPage } from '@/api/doormile';
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
/**
* Reading bookings for the assistant.
*
* `GET /admin/bookings` has no date, status or tenant filter and caps `pagesize`
* at 1000 server-side, so every question is answered by draining pages and
* filtering here. A single `getBookings(1, 1000)` silently under-reports the
* moment an account passes 1000 lifetime bookings, which is why nothing in the
* assistant calls it.
*
* Every scan returns `{ rows, truncated, scanned, pagesFetched, total }` rather
* than a bare array, and **`truncated` is not optional to handle**. A count
* built on a capped scan is a floor, not a total — run it through `countPhrase`,
* append `truncationNote` to the detail, and build the audit entry with
* `scanCall`, which reports an error rather than a green tick beside a partial
* number.
*/
const BULK_PAGESIZE = 1000;
/** 12k rows — generous, but bounded so one question cannot hammer the API. */
const MAX_PAGES = 12;
/**
* Row order is not documented. When page 1 comes back newest-first the scan can
* stop as soon as a page ends older than the window; otherwise it scans to the
* budget. Detected per call rather than assumed, so a backend change degrades to
* "scan everything" — slower, still correct — rather than to a wrong answer.
*/
const isDescendingByCreatedAt = (rows) => {
if (rows.length < 2) return false;
const first = parseDoormileTimestamp(rows[0].createdat);
const last = parseDoormileTimestamp(rows[rows.length - 1].createdat);
return first.isValid() && last.isValid() && first.valueOf() > last.valueOf();
};
const inRange = (booking, start, end) => {
const at = parseDoormileTimestamp(booking.createdat);
if (!at.isValid()) return false;
const day = at.format('YYYY-MM-DD');
return day >= start && day <= end;
};
/**
* A short-lived page cache.
*
* A multi-part question runs several intents, and a comparison runs two ranges —
* each would otherwise re-drain the same pages. Deliberately small and local:
* this is not a caching layer to settle on.
*/
const PAGE_CACHE_TTL_MS = 20_000;
const pageCache = new Map();
const getPageCached = (page) => {
const hit = pageCache.get(page);
if (hit && Date.now() - hit.at < PAGE_CACHE_TTL_MS) return hit.promise;
const promise = getBookingsPage(page, BULK_PAGESIZE).catch((err) => {
/* Never cache a failure — the next question should retry, not inherit it. */
pageCache.delete(page);
throw err;
});
pageCache.set(page, { at: Date.now(), promise });
return promise;
};
export const fetchBookingsInRange = async (start, end) => {
const firstPage = await getPageCached(1);
const total = firstPage.total;
const pageCount = Math.max(1, Math.ceil(total / BULK_PAGESIZE));
const budget = Math.min(pageCount, MAX_PAGES);
const collected = [...firstPage.rows];
const descending = isDescendingByCreatedAt(firstPage.rows);
/* Newest-first and this page already ends before the window opens → every
later page is older still, so there is nothing left to find. */
const pageEndsBeforeRange = (rows) => {
if (!descending || !rows.length) return false;
const oldest = parseDoormileTimestamp(rows[rows.length - 1].createdat);
return oldest.isValid() && oldest.format('YYYY-MM-DD') < start;
};
let stoppedEarly = pageEndsBeforeRange(firstPage.rows);
let lastPageFetched = 1;
for (let page = 2; page <= budget && !stoppedEarly; page += 1) {
const next = await getPageCached(page);
lastPageFetched = page;
if (!next.rows.length) {
stoppedEarly = true;
break;
}
collected.push(...next.rows);
stoppedEarly = pageEndsBeforeRange(next.rows);
}
return {
rows: collected.filter((booking) => inRange(booking, start, end)),
/* Truncated only if the budget ran out with pages still unread AND the scan
did not stop early because it had already passed the window. */
truncated: !stoppedEarly && pageCount > budget,
scanned: collected.length,
pagesFetched: lastPageFetched,
total,
};
};
export const fetchBookingsForDay = (day) => fetchBookingsInRange(day, day);
/**
* A full scan with no date window — for a per-order lookup, which has to look
* everywhere rather than inside a range. The sentinel bounds keep one code path:
* they can never trigger the early stop, so this always drains to the page
* budget and reports `truncated` honestly if the id could be further back.
*/
export const scanBookings = () => fetchBookingsInRange('0000-01-01', '9999-12-31');
/** A count over a truncated scan is a floor — say so. */
export const countPhrase = (scan, n) => `${scan.truncated ? 'At least ' : ''}${n}`;
export const truncationNote = (scan) =>
scan.truncated
? `\n\nScanned the most recent ${scan.scanned.toLocaleString('en-IN')} of ${scan.total.toLocaleString(
'en-IN'
)} bookings — this is a floor, not a complete count.`
: '';
/**
* The audit entry for a scan. Reports the real page count, and flags itself as
* an error when truncated so the sources strip cannot show a green "complete"
* beside a partial number.
*/
export const scanCall = (scan, note) => ({
name: 'getBookingsPage',
target: `/admin/bookings (${scan.pagesFetched} page${scan.pagesFetched === 1 ? '' : 's'} × ${BULK_PAGESIZE})`,
status: scan.truncated ? 'error' : 'complete',
errorMessage: scan.truncated ? `Scan capped at ${MAX_PAGES} pages; ${scan.total} bookings exist` : undefined,
stats: note,
});
/** A plain audit entry for a non-scan call. */
export const call = (name, target, stats) => ({ name, target, status: 'complete', stats });

342
src/lib/assistant/vocab.js Normal file
View File

@@ -0,0 +1,342 @@
import dayjs from 'dayjs';
import { getRowBatchId } from '@/lib/batchBucket';
import { ORDER_STATUS_GROUPS, ORDER_STATUS_LABELS, ORDER_STATUS_ORDER, groupForBookingStatus } from '@/lib/orderStatusGroups';
import { getalltenants } from '@/api/doormile';
/**
* The assistant's fixed vocabulary — dates, statuses, batches, typo tolerance.
*
* Deliberately a small hand-written vocabulary rather than a date-parsing
* library: an unrecognised phrase falls back to today, never to a guessed date.
* A wrong date silently answers a different question, which is worse than
* refusing.
*/
/* ── Typo tolerance ───────────────────────────────────────────────────────── */
/**
* Only these words are ever "corrected". Order numbers, tenant names and short
* words are never touched, so a correction can never invent a term the operator
* did not mean.
*/
const KEYWORD_VOCAB = [
'orders', 'order', 'bookings', 'booking', 'delivered', 'delivery', 'deliveries',
'cancelled', 'canceled', 'cancellation', 'pending', 'assigned', 'active',
'riders', 'rider', 'milers', 'miler', 'tenants', 'tenant', 'clients', 'client',
'customers', 'customer', 'hubs', 'vehicles', 'vehicle', 'tripsheets', 'tripsheet',
'exceptions', 'exception', 'pricing', 'revenue', 'today', 'yesterday', 'tomorrow',
'morning', 'afternoon', 'evening', 'batch', 'status', 'summary', 'total',
'available', 'offline', 'blocked', 'week', 'month', 'consignments', 'consignment',
'partners', 'partner', 'profitability', 'profit',
];
const levenshtein = (a, b) => {
if (a === b) return 0;
const rows = Array.from({ length: b.length + 1 }, (_, i) => [i, ...Array(a.length).fill(0)]);
for (let j = 1; j <= a.length; j += 1) rows[0][j] = j;
for (let i = 1; i <= b.length; i += 1) {
for (let j = 1; j <= a.length; j += 1) {
rows[i][j] = Math.min(
rows[i - 1][j] + 1,
rows[i][j - 1] + 1,
rows[i - 1][j - 1] + (a[j - 1] === b[i - 1] ? 0 : 1)
);
}
}
return rows[b.length][a.length];
};
/**
* Corrects misspelled domain keywords before matching. Words shorter than five
* characters are left alone — at that length almost anything is within edit
* distance of something, and "correcting" it changes the question.
*/
export const correctTypos = (text) =>
String(text)
.split(/(\s+)/)
.map((token) => {
const word = token.toLowerCase();
if (word.length < 5 || /[\d#-]/.test(word)) return token;
if (KEYWORD_VOCAB.includes(word)) return token;
const tolerance = word.length >= 8 ? 2 : 1;
const hit = KEYWORD_VOCAB.find((candidate) => levenshtein(word, candidate) <= tolerance);
return hit || token;
})
.join('');
/* ── Dates ────────────────────────────────────────────────────────────────── */
const WEEKDAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
const today = () => dayjs().format('YYYY-MM-DD');
/** An explicit DD/MM/YYYY or ISO date, or null. */
const explicitDateFromWords = (text) => {
const iso = text.match(/\b(\d{4})-(\d{2})-(\d{2})\b/);
if (iso) return iso[0];
const slash = text.match(/\b(\d{1,2})[/-](\d{1,2})[/-](\d{4})\b/);
if (slash) {
const parsed = dayjs(`${slash[3]}-${slash[2].padStart(2, '0')}-${slash[1].padStart(2, '0')}`);
if (parsed.isValid()) return parsed.format('YYYY-MM-DD');
}
return null;
};
/** The most recent past occurrence of a named weekday. */
const weekdayFromWords = (text) => {
const hit = WEEKDAYS.find((day) => new RegExp(`\\b${day}\\b`, 'i').test(text));
if (!hit) return null;
const target = WEEKDAYS.indexOf(hit);
let cursor = dayjs();
for (let i = 0; i < 7; i += 1) {
cursor = cursor.subtract(1, 'day');
if (cursor.day() === target) return cursor.format('YYYY-MM-DD');
}
return null;
};
/** Whether the question named a date at all — as opposed to us defaulting. */
export const mentionsAnyDate = (text) =>
/\b(today|yesterday|tomorrow|this week|last week|this month|last month|from .+ to )\b/i.test(text) ||
Boolean(explicitDateFromWords(text)) ||
Boolean(weekdayFromWords(text));
/** A single day named in the question, defaulting to today. */
export const dayFromWords = (text) => {
const explicit = explicitDateFromWords(text);
if (explicit) return explicit;
if (/\byesterday\b/i.test(text)) return dayjs().subtract(1, 'day').format('YYYY-MM-DD');
if (/\btomorrow\b/i.test(text)) return dayjs().add(1, 'day').format('YYYY-MM-DD');
const weekday = weekdayFromWords(text);
if (weekday) return weekday;
return today();
};
/** A `{ start, end, label }` window named in the question, defaulting to today. */
export const rangeFromWords = (text) => {
const explicitPair = text.match(/from\s+(\S+)\s+to\s+(\S+)/i);
if (explicitPair) {
const start = explicitDateFromWords(explicitPair[1]) || dayFromWords(explicitPair[1]);
const end = explicitDateFromWords(explicitPair[2]) || dayFromWords(explicitPair[2]);
if (start && end) return { start, end, label: `${start} to ${end}` };
}
if (/\blast week\b/i.test(text)) {
const base = dayjs().subtract(1, 'week');
return {
start: base.startOf('week').format('YYYY-MM-DD'),
end: base.endOf('week').format('YYYY-MM-DD'),
label: 'last week',
};
}
if (/\bthis week\b/i.test(text)) {
return {
start: dayjs().startOf('week').format('YYYY-MM-DD'),
end: dayjs().endOf('week').format('YYYY-MM-DD'),
label: 'this week',
};
}
if (/\blast month\b/i.test(text)) {
const base = dayjs().subtract(1, 'month');
return {
start: base.startOf('month').format('YYYY-MM-DD'),
end: base.endOf('month').format('YYYY-MM-DD'),
label: 'last month',
};
}
if (/\bthis month\b/i.test(text)) {
return {
start: dayjs().startOf('month').format('YYYY-MM-DD'),
end: dayjs().endOf('month').format('YYYY-MM-DD'),
label: 'this month',
};
}
const day = dayFromWords(text);
const label = day === today() ? 'today' : day;
return { start: day, end: day, label };
};
/* ── Domain guards ────────────────────────────────────────────────────────── */
/**
* Guards so a generic order/status intent refuses a question that is plainly
* about another domain. Deliberately redundant with intent ordering: if someone
* reorders the catalog later, these still hold.
*/
export const mentionsRiders = (text) => /\b(rider|riders|miler|milers)\b/i.test(text);
export const mentionsTenants = (text) => /\b(tenant|tenants|client|clients)\b/i.test(text);
export const mentionsFleet = (text) =>
/\b(hub|hubs|vehicle|vehicles|tripsheet|tripsheets|exception|exceptions|partner|partners)\b/i.test(text);
export const mentionsCustomers = (text) => /\b(customer|customers|recipient|recipients)\b/i.test(text);
/* ── Statuses and batches ─────────────────────────────────────────────────── */
/** An order-status group named in the question, using the Orders taxonomy. */
export const statusFromWords = (text) => {
const lower = text.toLowerCase();
if (/\bcancel(led|ed|lation|lations)?\b/.test(lower)) return 'cancelled';
if (/\bdeliver(ed|y|ies)?\b/.test(lower) && !/\bout for\b/.test(lower)) return 'delivered';
if (/\bout for delivery|in transit|on the road\b/.test(lower)) return 'active';
if (/\bassigned\b/.test(lower)) return 'assigned';
if (/\bpending|unassigned|waiting\b/.test(lower)) return 'pending';
return null;
};
export const batchFromWords = (text) => {
const lower = text.toLowerCase();
if (/\bmorning\b/.test(lower)) return 'morning';
if (/\bafternoon\b/.test(lower)) return 'afternoon';
if (/\bevening\b/.test(lower)) return 'evening';
return null;
};
export const isInStatusGroup = (booking, group) =>
(ORDER_STATUS_GROUPS[group] || []).includes(String(booking.status || '').toLowerCase());
export const batchOf = (booking) => getRowBatchId({ orderdate: booking.createdat });
/* ── Money and tallies ────────────────────────────────────────────────────── */
export const bookingCharge = (booking) => Number(booking?.serviceoptions?.[0]?.estimatedprice) || 0;
export const isCancelled = (booking) => String(booking.status || '').toLowerCase() === 'cancelled';
/**
* Estimated revenue. Sums the quoted price on non-cancelled rows — it is a
* quote, not a settled amount, and every answer says "estimated" for that
* reason.
*/
export const revenueOf = (rows) => rows.filter((row) => !isCancelled(row)).reduce((sum, row) => sum + bookingCharge(row), 0);
export const formatRupees = (value) =>
new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR', minimumFractionDigits: 2 }).format(value || 0);
/** A prose tally of statuses, using the Orders taxonomy. */
export const summarizeStatuses = (rows) => {
const counts = {};
rows.forEach((row) => {
const group = groupForBookingStatus(row.status);
const label = ORDER_STATUS_LABELS[group] || group;
counts[label] = (counts[label] || 0) + 1;
});
return Object.entries(counts)
.map(([label, value]) => `${value} ${label.toLowerCase()}`)
.join(', ');
};
/**
* The same tallies as `summarizeStatuses`, shaped for the panel's metric grid.
* Both read the same classification, so the sentence and the cards can never
* disagree. An enum the group map has never seen still shows, rather than being
* silently dropped from the totals.
*/
export const statusStats = (rows) => {
const counts = {};
rows.forEach((row) => {
const group = groupForBookingStatus(row.status);
counts[group] = (counts[group] || 0) + 1;
});
const known = ORDER_STATUS_ORDER.filter((group) => counts[group]).map((group) => ({
label: ORDER_STATUS_LABELS[group],
value: counts[group],
}));
const unmapped = Object.keys(counts)
.filter((group) => !ORDER_STATUS_ORDER.includes(group))
.map((group) => ({ label: group, value: counts[group] }));
return [...known, ...unmapped];
};
/** Count per distinct value of a field — the fleet intents all tally this way. */
export const tallyBy = (rows, field) => {
const counts = {};
(rows || []).forEach((row) => {
const key = row?.[field] || 'Unspecified';
counts[key] = (counts[key] || 0) + 1;
});
return Object.entries(counts).map(([label, value]) => ({ label: String(label), value }));
};
/**
* The best record whose name overlaps the phrase, in either direction — the
* question may name a shorter or a longer form than the record holds. Prefers
* the longest match, so "Acme" cannot beat "Acme Foods" when both exist.
*/
export const bestNameMatch = (needle, records, nameOf) => {
const query = String(needle).toLowerCase().trim();
if (query.length < 2) return null;
const hits = (records || []).filter((record) => {
const name = String(nameOf(record) || '').toLowerCase();
return name.length > 1 && (name.includes(query) || query.includes(name));
});
if (!hits.length) return null;
return hits.sort((a, b) => String(nameOf(b) || '').length - String(nameOf(a) || '').length)[0];
};
/**
* A specific order referenced in the question.
*
* `strong` marks an unmistakable reference (`#1234`, `DM-…`); a bare run of
* digits is weak, because it could equally be a year, a pincode or a quantity.
* The distinction decides what a miss means: a strong id that is not found is
* answered "I could not find it", a weak one falls through to broader intents
* rather than hard-failing a question that was never about one order.
*/
export const orderIdFromWords = (text) => {
const hash = text.match(/#\s*([A-Za-z0-9-]{3,})/);
if (hash) return { id: hash[1], strong: true };
const code = text.match(/\bDM-[A-Za-z0-9-]+\b/i);
if (code) return { id: code[0], strong: true };
/* Strip explicit dates first, so "order status on 12/08/2026" does not read
the year as an order number. */
const withoutDates = text.replace(/\b\d{1,4}[/-]\d{1,2}[/-]\d{2,4}\b/g, ' ');
const bare = withoutDates.match(/\b(\d{4,})\b/);
if (bare) return { id: bare[1], strong: false };
return null;
};
/** "today", or the date spelled out — for a headline that reads naturally
* either way ("3 orders today" vs "3 orders on 20 Aug 2026"). */
export const describeDay = (day) => (day === today() ? 'today' : dayjs(day).format('DD MMM YYYY'));
/** The first tenant whose name appears literally in the question. Bidirectional
* matching (`bestNameMatch`) is for a name the operator already named on
* purpose; this is for spotting one mentioned in passing ("orders for Acme
* today"), so a plain substring check is the right, narrower tool. */
export const resolveTenant = async (text) => {
const tenants = (await getalltenants()) || [];
const lower = String(text || '').toLowerCase();
return tenants.find((t) => t.tenantname && lower.includes(String(t.tenantname).toLowerCase())) || null;
};
/* Trailing verbs/adverbs a greedy name capture sweeps up — "hub Chennai
* status" must resolve to "Chennai", not "Chennai status". */
const NAME_TAIL_WORDS =
/\b(deliver(ed|y|ies)?|complete[d]?|assign(ed)?|do|did|does|has|have|is|are|was|were|today|yesterday|now|status|this|last|week|month|year)\b/gi;
const cleanEntityName = (raw) => {
const cleaned = String(raw || '')
.replace(NAME_TAIL_WORDS, ' ')
.replace(/[?.,]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
return cleaned.length >= 2 ? cleaned : null;
};
/** The name following a keyword like "hub" or "vehicle" in a lookup question —
* "status of hub Chennai" → "Chennai". */
export const nameAfterKeyword = (text, keyword) => {
const m = text.match(new RegExp(`\\b${keyword}\\s+(?:named\\s+|called\\s+)?([A-Za-z0-9][A-Za-z0-9 .'-]{1,40})`, 'i'));
return m ? cleanEntityName(m[1]) : null;
};
/** A tenant named after "for"/"tenant" — "orders for the Acme Foods today". */
export const tenantNameCandidate = (text) => {
const m = text.match(/\b(?:for|tenant)\s+(?:the\s+)?([A-Za-z][A-Za-z0-9 &.'-]{1,40})/i);
return m ? cleanEntityName(m[1]) : null;
};
export { ORDER_STATUS_LABELS, ORDER_STATUS_ORDER, groupForBookingStatus };

View File

@@ -1,389 +0,0 @@
/**
* Attendance and overtime, computed from shift records.
*
* Pure functions over the `ShiftRecord` collection: no fetching, no React, no
* knowledge of who is asking. The Owliver resolvers call these, and so could a
* page — which is the point, because a card and an answer that compute the
* same figure two different ways will eventually disagree.
*
* Nothing here is a canned response. Every function returns figures, and the
* wording is the caller's problem. That is what stops "Attendance Analysis"
* from being a fixed paragraph with numbers dropped into it.
*
* "Department" is `role_category`, the same field `lib/hiringRecords.js` joins
* a hire to its posting for — so a department means one thing across hiring,
* analytics and attendance.
*/
const HOUR_MS = 60 * 60 * 1000;
/** A shift that was actually worked, in whole or in part. */
export const wasWorked = (shift) => shift?.status !== 'absent' && shift?.status !== 'no_show';
/** A shift that was missed, however it was missed. */
export const wasMissed = (shift) => shift?.status === 'absent' || shift?.status === 'no_show';
const sum = (xs) => xs.reduce((a, b) => a + b, 0);
const round1 = (n) => Math.round(n * 10) / 10;
/** A percentage of a total, or 0 when there is no total to be a share of. */
export const rate = (part, total) => (total ? Math.round((part / total) * 100) : 0);
/**
* The headline attendance figures for a set of shifts.
*
* `attendanceRate` counts shifts *turned up for*, late or not — being late is a
* punctuality problem, not an absence, and folding the two together would make
* a reliably-late team look absent and a genuinely absent one look better than
* it is. Punctuality is reported separately for the same reason.
*/
export function attendanceSummary(shifts = []) {
const scheduled = shifts.length;
const worked = shifts.filter(wasWorked);
const late = shifts.filter((s) => s.status === 'late');
const absent = shifts.filter((s) => s.status === 'absent');
const noShow = shifts.filter((s) => s.status === 'no_show');
return {
scheduled,
worked: worked.length,
late: late.length,
absent: absent.length,
noShow: noShow.length,
missed: absent.length + noShow.length,
attendanceRate: rate(worked.length, scheduled),
punctualityRate: rate(worked.length - late.length, scheduled),
/* Minutes lost to late arrivals, which is the figure a manager can act on —
"four late arrivals" says nothing about whether it cost ten minutes or
two hours. */
minutesLate: sum(shifts.map((s) => s.minutes_late || 0)),
hoursScheduled: round1(sum(shifts.map((s) => s.scheduled_hours || 0))),
hoursWorked: round1(sum(shifts.map((s) => s.actual_hours || 0))),
empty: scheduled === 0,
};
}
/** The headline overtime figures for a set of shifts. */
export function overtimeSummary(shifts = []) {
const withOvertime = shifts.filter((s) => (s.overtime_hours || 0) > 0);
const hours = round1(sum(shifts.map((s) => s.overtime_hours || 0)));
const scheduledHours = round1(sum(shifts.map((s) => s.scheduled_hours || 0)));
return {
shifts: shifts.length,
shiftsWithOvertime: withOvertime.length,
hours,
hoursScheduled: scheduledHours,
hoursWorked: round1(sum(shifts.map((s) => s.actual_hours || 0))),
/* Overtime as a share of scheduled time: 40 hours means one thing across a
fortnight and another across a year, and the ratio is what makes the two
comparable. */
overtimeShare: scheduledHours ? Math.round((hours / scheduledHours) * 100) : 0,
averagePerShift: shifts.length ? round1(hours / shifts.length) : 0,
empty: shifts.length === 0,
};
}
/** Shifts grouped by a key, as `[key, shifts]` pairs — most shifts first. */
function groupBy(shifts, key) {
const groups = new Map();
for (const shift of shifts) {
const value = shift?.[key] || '—';
if (!groups.has(value)) groups.set(value, []);
groups.get(value).push(shift);
}
return [...groups.entries()].sort((a, b) => b[1].length - a[1].length);
}
/**
* Per-worker attendance, worst attendance first.
*
* Ordered by who needs attention rather than alphabetically: a list of people
* sorted by name buries the one person the reader opened it for.
*/
export function attendanceByWorker(shifts = []) {
return groupBy(shifts, 'worker_name')
.map(([name, theirs]) => ({
id: theirs[0].staff_id,
name,
email: theirs[0].worker_email,
role: theirs[0].role,
department: theirs[0].role_category,
...attendanceSummary(theirs),
}))
.sort((a, b) => a.attendanceRate - b.attendanceRate || b.minutesLate - a.minutesLate);
}
/** Per-worker overtime, most overtime first. */
export function overtimeByWorker(shifts = []) {
return groupBy(shifts, 'worker_name')
.map(([name, theirs]) => ({
id: theirs[0].staff_id,
name,
email: theirs[0].worker_email,
role: theirs[0].role,
department: theirs[0].role_category,
...overtimeSummary(theirs),
}))
.sort((a, b) => b.hours - a.hours);
}
/** Per-department attendance, worst attendance first. */
export function attendanceByDepartment(shifts = []) {
return groupBy(shifts, 'role_category')
.map(([department, theirs]) => ({
id: department,
name: department,
department,
people: new Set(theirs.map((s) => s.staff_id)).size,
...attendanceSummary(theirs),
}))
.sort((a, b) => a.attendanceRate - b.attendanceRate);
}
/** Per-department overtime, most overtime first. */
export function overtimeByDepartment(shifts = []) {
return groupBy(shifts, 'role_category')
.map(([department, theirs]) => ({
id: department,
name: department,
department,
people: new Set(theirs.map((s) => s.staff_id)).size,
...overtimeSummary(theirs),
}))
.sort((a, b) => b.hours - a.hours);
}
/* ── Trends ─────────────────────────────────────────────────────────────── */
const startOfDay = (d) => {
const copy = new Date(d);
copy.setHours(0, 0, 0, 0);
return copy;
};
/** The Monday of the week a date falls in. */
function startOfWeek(date) {
const day = startOfDay(date);
const weekday = (day.getDay() + 6) % 7;
return new Date(day.getTime() - weekday * 24 * HOUR_MS);
}
const pad = (n) => String(n).padStart(2, '0');
const isoDay = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
/**
* Attendance and overtime week by week, oldest first.
*
* Whole weeks rather than rolling days, because shift patterns *are* weekly —
* a seven-day rolling window over a Monday-to-Friday rota reports a different
* denominator depending on which day you happen to read it.
*
* Weeks with no shifts scheduled are dropped rather than reported as 0%
* attendance: nobody failed to attend a week they were never rostered for, and
* a zero there would read as a catastrophe.
*/
export function weeklyTrend(shifts = [], { weeks = 8, now = new Date() } = {}) {
const buckets = new Map();
for (const shift of shifts) {
const at = new Date(shift.created_date || shift.scheduled_start || 0);
if (Number.isNaN(at.getTime())) continue;
const key = isoDay(startOfWeek(at));
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key).push(shift);
}
const thisWeek = startOfWeek(now);
return [...buckets.entries()]
.filter(([key]) => {
const weeksBack = Math.round((thisWeek.getTime() - new Date(key).getTime()) / (7 * 24 * HOUR_MS));
return weeksBack >= 0 && weeksBack < weeks;
})
.sort((a, b) => new Date(a[0]).getTime() - new Date(b[0]).getTime())
.map(([key, theirs]) => {
const attendance = attendanceSummary(theirs);
const overtime = overtimeSummary(theirs);
return {
id: key,
weekStart: key,
label: new Date(key).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
scheduled: attendance.scheduled,
worked: attendance.worked,
missed: attendance.missed,
late: attendance.late,
attendanceRate: attendance.attendanceRate,
overtimeHours: overtime.hours,
hoursWorked: attendance.hoursWorked,
};
});
}
/* ── Anomalies ──────────────────────────────────────────────────────────── */
/**
* How many recent weeks count as "now" when comparing against what came before.
* Two, because one week is noise — a single bad week is a bad week, and it takes
* a second to be a direction.
*/
const RECENT_WEEKS = 2;
/**
* Changes worth surfacing, and nothing else.
*
* The rule this file exists to enforce is *not flagging everything*. A report
* that lists every movement trains its reader to skim it, and the one finding
* that mattered goes past unread. So a finding has to clear a floor on both
* axes: enough shifts behind it to mean anything, and a big enough change to
* be worth someone's afternoon.
*
* Each finding carries the figures it was derived from, so a caller can state
* *why* rather than asserting that something is wrong.
*/
export function attendanceAnomalies(shifts = [], { now = new Date() } = {}) {
const findings = [];
const trend = weeklyTrend(shifts, { weeks: 12, now });
if (trend.length < RECENT_WEEKS + 1) return findings;
const recent = trend.slice(-RECENT_WEEKS);
const earlier = trend.slice(0, -RECENT_WEEKS);
const flatten = (weeks) => ({
scheduled: sum(weeks.map((w) => w.scheduled)),
missed: sum(weeks.map((w) => w.missed)),
late: sum(weeks.map((w) => w.late)),
overtime: round1(sum(weeks.map((w) => w.overtimeHours))),
});
const now_ = flatten(recent);
const before = flatten(earlier);
/* Too little to reason about. Saying so is better than dividing by four. */
if (now_.scheduled < 4 || before.scheduled < 4) return findings;
/* ── Missed shifts, workspace-wide ──────────────────────────────────── */
const missedNow = rate(now_.missed, now_.scheduled);
const missedBefore = rate(before.missed, before.scheduled);
if (now_.missed >= 2 && missedNow - missedBefore >= 8) {
findings.push({
id: 'missed-shifts-rising',
kind: 'attendance',
severity: missedNow - missedBefore >= 15 ? 'high' : 'medium',
title: 'Missed shifts are rising',
detail: `${now_.missed} of ${now_.scheduled} shifts missed in the last ${RECENT_WEEKS} weeks (${missedNow}%), against ${missedBefore}% before that.`,
metric: 'missed',
current: missedNow,
previous: missedBefore,
});
}
/* ── Lateness, workspace-wide ───────────────────────────────────────── */
const lateNow = rate(now_.late, now_.scheduled);
const lateBefore = rate(before.late, before.scheduled);
if (now_.late >= 3 && lateNow - lateBefore >= 10) {
findings.push({
id: 'lateness-rising',
kind: 'attendance',
severity: 'medium',
title: 'Late arrivals are rising',
detail: `${now_.late} late arrivals in the last ${RECENT_WEEKS} weeks (${lateNow}% of shifts), against ${lateBefore}% before that.`,
metric: 'late',
current: lateNow,
previous: lateBefore,
});
}
/* ── Overtime, as a direction rather than a spike ────────────────────────
*
* Deliberately not the two-window comparison used above. Overtime that grows
* half an hour a week never produces a step big enough to trip a threshold —
* each week looks like the last — and yet a rota that has quietly gained
* three hours a shift over two months is exactly the finding worth having.
* Averaging the recent weeks against the earlier ones actively hides it,
* because the mean of a rising series sits in the middle of it.
*
* So this looks at the *shape*: overtime per scheduled shift, rising through
* most of the series and materially higher at the end than the start.
*
* Per shift, not per week, and complete weeks only. The week in progress has
* had fewer shifts worked in it so far, and comparing a part-week total
* against whole ones reports a fall in overtime every Monday morning.
*/
const thisWeekStart = startOfWeek(now).getTime();
const complete = trend.filter((w) => new Date(w.weekStart).getTime() < thisWeekStart);
if (complete.length >= 4) {
const perShift = complete.map((w) => (w.scheduled ? w.overtimeHours / w.scheduled : 0));
const rising = perShift.slice(1).filter((value, i) => value > perShift[i]).length;
const first = perShift[0];
const last = perShift[perShift.length - 1];
const totalOvertime = round1(sum(complete.map((w) => w.overtimeHours)));
/* Three quarters of the steps going the same way, a half again at the end,
and enough hours behind it to be worth someone's time. Any one of those
alone would fire on noise. */
const sustained = rising >= Math.ceil((perShift.length - 1) * 0.75);
if (sustained && first > 0 && last >= first * 1.5 && totalOvertime >= 8) {
const from = complete[0];
const to = complete[complete.length - 1];
findings.push({
id: 'overtime-climbing',
kind: 'overtime',
severity: last >= first * 2 ? 'high' : 'medium',
title: 'Overtime has been climbing week on week',
detail: `Up from ${from.overtimeHours}h in the week of ${from.label} to ${to.overtimeHours}h in the week of ${to.label} — rising in ${rising} of the last ${perShift.length - 1} weeks.`,
metric: 'overtime-trend',
current: to.overtimeHours,
previous: from.overtimeHours,
weeks: perShift.length,
});
}
}
/* ── Individuals, where the workspace-wide figure hides them ─────────── */
const recentFrom = new Date(startOfWeek(now).getTime() - (RECENT_WEEKS - 1) * 7 * 24 * HOUR_MS);
const recentShifts = shifts.filter(
(s) => new Date(s.created_date || 0).getTime() >= recentFrom.getTime()
);
for (const worker of attendanceByWorker(recentShifts)) {
/* Four shifts is the floor for saying anything about a person at all. */
if (worker.scheduled < 4) continue;
if (worker.missed >= 2 && worker.attendanceRate <= 85) {
findings.push({
id: `worker-attendance-${worker.id}`,
kind: 'attendance',
severity: worker.attendanceRate <= 75 ? 'high' : 'medium',
title: `${worker.name} has missed ${worker.missed} of ${worker.scheduled} recent shifts`,
detail: `${worker.attendanceRate}% attendance over the last ${RECENT_WEEKS} weeks in ${worker.department}.`,
metric: 'attendance',
current: worker.attendanceRate,
previous: null,
workerId: worker.id,
});
}
}
for (const worker of overtimeByWorker(recentShifts)) {
if (worker.shifts < 4) continue;
/* A quarter of scheduled time again is where overtime stops being a busy
fortnight and starts being how the rota is actually staffed. */
if (worker.overtimeShare >= 25) {
findings.push({
id: `worker-overtime-${worker.id}`,
kind: 'overtime',
severity: worker.overtimeShare >= 40 ? 'high' : 'medium',
title: `${worker.name} is working ${worker.overtimeShare}% overtime`,
detail: `${worker.hours}h of overtime across ${worker.shifts} recent shifts in ${worker.department}.`,
metric: 'overtime-share',
current: worker.overtimeShare,
previous: null,
workerId: worker.id,
});
}
}
const rank = { high: 0, medium: 1, low: 2 };
return findings.sort((a, b) => rank[a.severity] - rank[b.severity]);
}

73
src/lib/batchBucket.js Normal file
View File

@@ -0,0 +1,73 @@
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
// The canonical Morning/Afternoon/Evening wave definitions, in one place so
// the Deliveries page and the Dispatch board cannot disagree about which wave
// a row belongs to — a row bucketed one way on one page and another way on the
// other is the same bug twice.
//
// Half-open [startHour, endHour) ranges in LOCAL time, and they COVER THE
// WHOLE DAY. That is the point.
//
// The windows used to be 0–8, 9–12.5 and 16–19 — 14.5 of 24 hours, with gaps
// at 8–9am, 12:30–4pm and after 7pm. Those came from a backend that bucketed
// on `expecteddeliverytime`, where they described PROMISED DELIVERY SLOTS.
// Bucketing runs on `orderdate` (when the order was placed) instead, and
// orders are placed all day: an order created at 2:43pm fell in the 12:30–4pm
// gap, belonged to no batch, and vanished from every batch filter. 40% of the
// clock was a black hole.
//
// Each window now runs to the start of the next, so every row lands in exactly
// one batch. Every START hour is unchanged, which makes the fix strictly
// additive: no row that already had a batch moved.
export const BATCHES = [
{ id: 'morning', label: 'Morning Batch', startHour: 0, endHour: 9 },
{ id: 'afternoon', label: 'Afternoon Batch', startHour: 9, endHour: 16 },
{ id: 'evening', label: 'Evening Batch', startHour: 16, endHour: 24 }
];
export const getBatchForHour = (h) => {
for (const b of BATCHES) {
if (h >= b.startHour && h < b.endHour) return b.id;
}
return null;
};
// Human-readable window, e.g. "12 AM–9 AM" / "After 4 PM". Kept here so the
// pages' pickers describe the same ranges they filter by.
const clock = (h) => {
const whole = Math.floor(h);
const mins = Math.round((h - whole) * 60);
const suffix = whole >= 12 ? 'PM' : 'AM';
const hour12 = whole % 12 === 0 ? 12 : whole % 12;
return mins ? `${hour12}:${String(mins).padStart(2, '0')} ${suffix}` : `${hour12} ${suffix}`;
};
export const batchRangeLabel = (b) => (b.endHour >= 24 ? `After ${clock(b.startHour)}` : `${clock(b.startHour)}–${clock(b.endHour)}`);
// The one field batch bucketing uses across the app: a booking's `orderdate`
// (== createdat — see `fetchDeliveries`).
//
// `assigntime` and `expecteddeliverytime` were both tried and rejected.
// `assigntime` maps to the booking's last-modified column — there is no
// assignment timestamp on this backend — so it is re-stamped by any status
// change and rows drift into whichever batch holds the current clock time.
// `expecteddeliverytime` is stable but describes the PROMISED slot, not the
// wave the order was placed in, so an order created at 12:22 with a 16:30 ETA
// counted towards Evening and the Evening batch showed orders nobody had
// placed yet. Creation time has neither failure mode: it is immutable, and no
// batch later than the current clock time can show a count for today.
export const getRowBatchId = (row) => {
const t = row?.orderdate;
if (!t) return null;
const str = String(t).trim();
// Skip bare date strings — no time component, would always parse to midnight.
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return null;
const d = parseDoormileTimestamp(t);
if (!d.isValid()) return null;
// Fractional hour so e.g. 12:45 falls into the slot starting at 12.5, not
// the one starting at 12 — d.hour() alone truncates and mis-buckets the
// back half of every hour.
return getBatchForHour(d.hour() + d.minute() / 60);
};
export const getBatchLabel = (batchId) => BATCHES.find((b) => b.id === batchId)?.label || batchId;

150
src/lib/bulkOrderColumns.js Normal file
View File

@@ -0,0 +1,150 @@
// ==============================|| Bulk-order sheet columns ||============================== //
//
// The single definition of what a bulk-order spreadsheet may look like. Two
// consumers share it:
//
// • pages/nearle/orders/multipleOrders.js — the console's bulk-upload page,
// where this map originated.
// • pages/nearle/assistant/bulkFile.js — Doormile AI's file upload.
//
// It lives here so a sheet that uploads on the page also uploads in the bot,
// permanently. Copying it into the assistant was the alternative and was
// rejected for the obvious reason: five pages once carried private STATUS_META
// copies that drifted into disagreeing about the same order's label.
//
// SHEET_HEADER_MAP is reproduced from the page byte for byte, including the `*`
// suffix convention (a starred header is required) and the note about which
// mappings were never confirmed against a live tenant sheet. Do not "tidy" the
// keys — they are the tenant's actual column titles, spaces and all.
export const normalizeHeader = (header) => header?.toString().trim().toLowerCase().replace(/\s+/g, '');
// A receiver name arrives prefixed with the sheet's own row numbering
// ("12. Ravi Kumar") often enough that stripping it is part of parsing.
export const cleanReceiverName = (name) => (typeof name === 'string' ? name.replace(/^[\d.\s]+/, '').trim() : name);
export const SHEET_HEADER_MAP = {
'pickupdate(yyyy-mmm-dd)': 'date',
'sendername*': 'locationname',
'senderphone*': 'locationcontact',
'senderaddress*': 'locationaddress',
'receivername*': 'firstname',
receiverphone: 'contactno',
'receiveralternatephone*': 'altcontactno',
receiverfulladdress: 'address',
// Best-effort — no live sample sheet to confirm these are the tenant's
// actual column headers. Without them deliverycity/deliverypincode in
// the submit payload were always sent blank; if these names are wrong,
// behavior is unchanged from before (still blank), not worse.
receivercity: 'city',
receiverpincode: 'postcode',
receiverlatitude: 'latitude',
receiverlongitude: 'longitude',
'itemdescription*': 'description',
Quantity: 'quantity',
' Collect Cash': 'collectionamt'
};
// Starred headers are the ones the page reports as missing.
export const requiredSheetColumns = () => Object.keys(SHEET_HEADER_MAP).filter((k) => k.trim().endsWith('*'));
// ---- assistant-side translation ---------------------------------------------
//
// The page consumes the internal names above (`firstname`, `contactno`, …)
// because its row editors were built on them. The assistant's rows are shaped
// like a booking payload instead, so the two are bridged here rather than in
// either consumer.
const INTERNAL_TO_ROW = {
firstname: 'customer_name',
contactno: 'customer_phone',
address: 'deliveryaddress',
postcode: 'deliverypincode',
city: 'deliverycity',
latitude: 'deliverylatitude',
longitude: 'deliverylongitude',
description: 'itemdescription',
quantity: 'quantity'
};
// Headers someone typing their own sheet actually writes. Accepted in addition
// to the tenant's official titles, never instead of them.
//
// `collectionamt` (the sheet's "Collect Cash") is deliberately NOT treated as a
// price: it is cash to collect from the recipient, while `finalprice` is what
// the delivery costs. Mapping one onto the other would quietly bill the wrong
// number on every row.
const PLAIN_ALIASES = {
customer_name: ['name', 'customername', 'customer', 'recipient', 'recipientname', 'receiver', 'to'],
customer_phone: ['phone', 'mobile', 'phonenumber', 'mobilenumber', 'customerphone', 'contact', 'recipientphone'],
deliveryaddress: ['address', 'deliveryaddress', 'dropaddress', 'fulladdress', 'destination'],
deliverypincode: ['pincode', 'pin', 'postcode', 'zip', 'zipcode', 'deliverypincode'],
deliverycity: ['city', 'town', 'deliverycity'],
deliverylatitude: ['latitude', 'lat', 'deliverylatitude'],
deliverylongitude: ['longitude', 'lng', 'lon', 'deliverylongitude'],
itemdescription: ['description', 'item', 'itemdescription', 'contents', 'goods', 'particulars'],
itemcategory: ['category', 'itemcategory', 'type'],
quantity: ['quantity', 'qty', 'parcels', 'pieces', 'count'],
finalprice: ['price', 'amount', 'charge', 'deliverycharge', 'finalprice', 'rate', 'fare'],
weight: ['weight', 'kg', 'kgs']
};
// normalised header → assistant row field. Built once; the tenant's official
// titles win over a plain alias if a sheet somehow carries both.
const ROW_FIELD_BY_HEADER = (() => {
const out = {};
Object.entries(PLAIN_ALIASES).forEach(([field, headers]) => {
headers.forEach((h) => {
out[normalizeHeader(h)] = field;
});
});
Object.entries(SHEET_HEADER_MAP).forEach(([sheetHeader, internal]) => {
const field = INTERNAL_TO_ROW[internal];
if (!field) return;
out[normalizeHeader(sheetHeader)] = field;
out[normalizeHeader(sheetHeader).replace(/\*+$/, '')] = field;
});
return out;
})();
// The `*` in a sheet title is a "this one is required" annotation the tenant
// types, not part of the column's name — so `Receiver Phone*` and
// `ReceiverPhone` are the same column and both must resolve. Only the
// assistant's lookup is star-tolerant; normalizeHeader itself is untouched,
// because multipleOrders.js derives its missing-required warning from the star
// and loosening that would change the page's behaviour.
const unstar = (h) => normalizeHeader(h).replace(/\*+$/, '');
export const rowFieldForHeader = (header) => ROW_FIELD_BY_HEADER[normalizeHeader(header)] || ROW_FIELD_BY_HEADER[unstar(header)] || null;
// Every field the assistant can fill from a sheet, for the downloadable template.
export const TEMPLATE_HEADERS = [
'Receiver Name*',
'Receiver Phone*',
'Receiver Full Address*',
'Receiver Pincode*',
'Receiver City',
'Item Description*',
'Quantity',
'Price'
];
// ---- one sheet row → one assistant row --------------------------------------
//
// Unrecognised columns are RETURNED, not dropped silently: an operator whose
// price column is titled something unexpected needs to be told it was ignored,
// or they'll submit 200 orders priced from a column that was never read.
export const mapSheetRow = (raw) => {
const row = {};
const ignored = [];
Object.entries(raw || {}).forEach(([header, value]) => {
const field = rowFieldForHeader(header);
if (!field) {
if (String(header || '').trim() && String(value ?? '').trim()) ignored.push(String(header).trim());
return;
}
row[field] = field === 'customer_name' ? cleanReceiverName(value) : value;
});
return { row, ignored };
};

View File

@@ -1,235 +0,0 @@
/**
* Candidate intelligence — every derived figure the profile shows.
*
* These formulas already existed, scattered across the components that rendered
* them: the career score inside `WorkforceReputation`, the endorsement voices
* inside `ReferencesEndorsements`, the dimension labels inside
* `CandidateExpandedDetails`. Rendering was rewritten; the arithmetic was not.
* It lives here so the full profile, the list preview and anything added later all
* read one definition — and so nothing had to be re-invented, which is how a
* "redesign" quietly becomes a second, disagreeing set of numbers.
*
* Pure: a candidate record in, derived values out. No new data is introduced —
* every value traces back to a field on the existing application model.
*/
import { getScoreBand, toFICO } from '@/lib/talentHome';
/* ── Score dimensions ───────────────────────────────────────────────────── */
/**
* The 15 screening dimensions, grouped into the four things a hiring decision
* actually turns on.
*
* Ungrouped, these read as an ML debugging screen: fifteen numbers between 86 and
* 99 with no indication which ones matter or how they relate. Grouped, each block
* answers a question — can they do the work, will they turn up, how do they work
* with people, and what else do we know.
*
* Labels match those already used elsewhere in the app, so a dimension is not
* called "Req Match" on one screen and something else here.
*/
export const SCORE_GROUPS = [
{
id: 'core',
label: 'Core match',
description: 'Can they do this job',
dimensions: [
{ key: 'experience', label: 'Experience' },
{ key: 'certifications', label: 'Certifications' },
{ key: 'employer_requirements', label: 'Requirement fit' },
{ key: 'verified_skills', label: 'Skills' },
],
},
{
id: 'reliability',
label: 'Reliability',
description: 'Will they turn up and hold the standard',
dimensions: [
{ key: 'reliability', label: 'Reliability' },
{ key: 'availability', label: 'Availability' },
{ key: 'attendance_expectations', label: 'Attendance' },
{ key: 'scenario_judgment', label: 'Judgment' },
],
},
{
id: 'style',
label: 'Work style',
description: 'How they work with a team',
dimensions: [
{ key: 'communication_style', label: 'Communication' },
{ key: 'culture_fit', label: 'Culture fit' },
{ key: 'leadership_expectations', label: 'Leadership' },
],
},
{
id: 'additional',
label: 'Additional signals',
description: 'Everything else screening captured',
dimensions: [
{ key: 'english', label: 'English' },
{ key: 'personality', label: 'Personality' },
{ key: 'job_related_answers', label: 'Job knowledge' },
{ key: 'physical_requirements', label: 'Physical' },
],
},
];
/** Every dimension key, in display order — used to prove none is dropped. */
export const ALL_DIMENSIONS = SCORE_GROUPS.flatMap((g) => g.dimensions);
/* ── Workforce reputation ───────────────────────────────────────────────── */
const clampScore = (n) => Math.max(0, Math.min(100, Math.round(n || 0)));
/** Career score on the 300–850 scale. Same formula as the talent-side profile. */
export const careerScore = (candidate) => toFICO(clampScore(candidate?.ai_score));
/** Career tier — Elite, Excellent, Solid, Building, New. */
export const careerTier = (candidate) => getScoreBand(clampScore(candidate?.ai_score));
/**
* Potential market value.
*
* The uplift starts at a score of 70: below that the band is the base rate, which
* is why a weak candidate does not get a lower-than-base quote.
*/
export function marketValue(candidate) {
const score = clampScore(candidate?.ai_score);
const uplift = Math.max(0, Math.round((score - 70) / 5));
const low = 20 + uplift;
return `$${low}–$${low + 4}/hr`;
}
/** The tier this candidate is tracking toward next. */
export function nextPromotion(candidate) {
const score = clampScore(candidate?.ai_score);
const years = candidate?.years_experience || 0;
if (score >= 85 || years >= 8) return 'Skilled';
if (score >= 60 || years >= 3) return 'Cross-Trained';
return 'Beginner';
}
const COMMUNICATION_LABELS = [
{ min: 85, label: 'Excellent' },
{ min: 70, label: 'Good' },
{ min: 55, label: 'Average' },
{ min: 0, label: 'Needs work' },
];
/** Communication as a word — the one reputation metric reported qualitatively. */
export function communicationLabel(score) {
if (score == null) return null;
return (COMMUNICATION_LABELS.find((c) => score >= c.min) ?? COMMUNICATION_LABELS.at(-1)).label;
}
/**
* The five headline reputation metrics.
*
* Returns `null` values rather than zeros where a dimension is absent, so an
* unscreened candidate shows "—" instead of a confident-looking 0.
*/
export function reputationMetrics(candidate) {
const b = candidate?.score_breakdown || {};
return [
{ label: 'Reliability', value: b.reliability ?? null, suffix: '%' },
{ label: 'Attendance', value: b.attendance_expectations ?? null, suffix: '%' },
{ label: 'Leadership', value: b.leadership_expectations ?? null, suffix: '%' },
{ label: 'Communication', value: communicationLabel(b.communication_style ?? null) },
{
label: 'Client rating',
value: candidate?.client_rating > 0 ? candidate.client_rating.toFixed(1) : null,
suffix: '/5',
},
];
}
/* ── Endorsements ───────────────────────────────────────────────────────── */
const ENDORSER_NAMES = ['Luis', 'Chef Antonio', 'Maria', 'David', 'Sofia'];
const ENDORSER_ROLES = ['Supervisor', 'Chef', 'Manager', 'Lead', 'Owner'];
/**
* Endorsement voices.
*
* Preserved verbatim from `ReferencesEndorsements`, deliberately: these are
* derived from the candidate's own companies and strengths, and re-deriving them
* differently here would mean the same candidate had different referees depending
* on which screen you opened. Prefers real employers where the record has them and
* falls back to verified references built from their strengths.
*/
export function endorsements(candidate) {
const companies = (candidate?.companies_worked || []).filter(Boolean);
const strengths = (candidate?.ai_strengths || []).filter(Boolean);
const years = candidate?.years_experience || 0;
const source = companies.length
? companies.slice(0, 3)
: strengths.slice(0, 3).map(() => 'Verified Reference');
return source.map((company, i) => ({
id: `${candidate?.id || 'c'}-endorsement-${i}`,
name: ENDORSER_NAMES[i % ENDORSER_NAMES.length],
role: ENDORSER_ROLES[i % ENDORSER_ROLES.length],
company,
events: 8 + i * 17 + years,
rating: 5,
comment: strengths[i]
? `${strengths[i]}. Highly recommend for any team.`
: 'Reliable, punctual, and a great team player. Would hire again.',
}));
}
/** Verified strength tags — the candidate's strengths plus their skills. */
export function strengthTags(candidate) {
return [...(candidate?.ai_strengths || []), ...(candidate?.skills || [])]
.filter(Boolean)
.slice(0, 6);
}
/* ── Activity ───────────────────────────────────────────────────────────── */
/** The candidate's history, assembled from their record and any interview. */
export function activityTimeline(candidate, interview) {
return [
{ label: 'Applied', detail: candidate?.job_title, date: candidate?.created_date },
candidate?.ai_score > 0 && {
label: 'AI screened',
detail: `Scored ${candidate.ai_score} · ${candidate.ai_match_label || 'no label'}`,
date: candidate.updated_date,
},
interview && {
label: 'Interview started',
detail: interview.summary ? undefined : 'No summary recorded',
date: interview.created_date,
},
interview?.overall_interview_score > 0 && {
label: 'Interview scored',
detail: `${interview.overall_interview_score}/100 · integrity ${interview.integrity_score ?? 100}`,
date: interview.updated_date || interview.created_date,
},
candidate?.ai_recommendation && {
label: `Recommendation: ${candidate.ai_recommendation}`,
date: candidate.updated_date,
},
candidate?.status === 'hired' && { label: 'Hired', date: candidate.updated_date },
candidate?.status === 'rejected' && { label: 'Declined', date: candidate.updated_date },
].filter(Boolean);
}
/* ── Recommendation ─────────────────────────────────────────────────────── */
/**
* The recommendation headline: a verdict plus the action it implies.
*
* Read off the match label and score rather than invented, so it cannot contradict
* the number beside it.
*/
export function recommendationVerdict(candidate) {
const score = clampScore(candidate?.ai_score);
if (!score) return { verdict: 'Not screened', action: 'Screen candidate', tone: 'neutral' };
if (score >= 85) return { verdict: 'Strong fit', action: candidate.ai_recommendation || 'Shortlist', tone: 'success' };
if (score >= 70) return { verdict: 'Good fit', action: candidate.ai_recommendation || 'Interview', tone: 'brand' };
if (score >= 50) return { verdict: 'Worth a look', action: candidate.ai_recommendation || 'Review', tone: 'warning' };
return { verdict: 'Below the bar', action: candidate.ai_recommendation || 'Review', tone: 'neutral' };
}

270
src/lib/dispatchPreview.js Normal file
View File

@@ -0,0 +1,270 @@
/**
* The dispatch preview's data model.
*
* The optimiser answers with a zoned tree — `zones[].riders[].orders[]` — plus
* a flat `details[]` list, and every edit on the preview page has to keep both
* in step: the board renders from the tree, and the commit payload is built
* from the flat list. Doing that in one place is what stops an order being
* shown against one rider and assigned to another.
*
* Ported from the Express Console's `dispatch/Preview.js`, where these were
* module-local; the logic is unchanged.
*/
// Flatten the API's zoned shape into [{ rider_id, rider_name, orders }] for
// the Reconcile tab UI and the reconcile-API payload.
export const extractRiders = (previewData) => {
if (!previewData) return [];
const map = new Map();
// De-dupe by orderid across the whole tree. A rider can legitimately appear
// in multiple zones (one per delivery suburb), so the same rider_id is
// visited more than once. Without this guard, any stale copy left behind
// by applyReconcileResponse gets concatenated into the rider's orders and
// the same orderid is sent twice to /deliveries/createdeliveries.
const seenOrderIds = new Set();
const push = (riderId, riderName, orders) => {
if (riderId == null) return;
const key = String(riderId);
if (!map.has(key)) {
map.set(key, { rider_id: riderId, rider_name: riderName, orders: [] });
}
const entry = map.get(key);
(orders || []).forEach((o) => {
const oid = o?.orderid != null ? String(o.orderid) : null;
if (oid) {
if (seenOrderIds.has(oid)) return;
seenOrderIds.add(oid);
}
entry.orders.push(o);
});
if (!entry.rider_name && riderName) entry.rider_name = riderName;
};
if (Array.isArray(previewData.zones) && previewData.zones.length) {
previewData.zones.forEach((z) => {
(z.riders || []).forEach((r) => {
const id = r.rider_id ?? r.userid;
const name = r.rider_name || r.username || `Rider ${id}`;
push(id, name, r.orders);
});
});
} else if (Array.isArray(previewData.details)) {
previewData.details.forEach((o) => {
const id = o.rider_id ?? o.userid;
const name = o.rider_name || o.ridername || `Rider ${id}`;
push(id, name, [o]);
});
}
return Array.from(map.values());
};
// Reverse of extractRiders — flatten rider-grouped list into a details-style
// array (used as the Assign Orders payload).
export const flattenRiders = (riders) => {
const out = [];
riders.forEach((r) => {
// Go backend types Deliveries.userid as int — coerce here so any
// upstream string (AI response, riders API, change-rider edit) gets
// normalised before the JSON body is built.
const ridNum = Number(r.rider_id);
const rid = Number.isFinite(ridNum) ? ridNum : r.rider_id;
(r.orders || []).forEach((o) => {
out.push({
...o,
rider_id: rid,
userid: rid,
rider_name: r.rider_name,
rider: r.rider_name
});
});
});
return out;
};
// Move one order from oldRiderId -> newRiderId inside dispatchPreviewData.
// Mutates both the zones[].riders[].orders[] tree (so the Dispatch tab
// renders the change) AND the flat details[] list (so Assign Orders picks
// it up). Returns a NEW preview object (immutable update).
export const moveOrderInPreviewData = (preview, { orderId, newRiderId, newRiderName }) => {
if (!preview) return preview;
const next = JSON.parse(JSON.stringify(preview));
// 1) Update flat details list
if (Array.isArray(next.details)) {
next.details = next.details.map((o) =>
String(o.orderid) === String(orderId)
? { ...o, rider_id: newRiderId, userid: newRiderId, rider_name: newRiderName, rider: newRiderName }
: o
);
}
// 2) Move within zones[].riders[].orders[]
if (Array.isArray(next.zones)) {
let movedOrder = null;
let homeZoneIdx = -1;
for (let zi = 0; zi < next.zones.length && !movedOrder; zi++) {
const zone = next.zones[zi];
if (!Array.isArray(zone.riders)) continue;
for (let ri = 0; ri < zone.riders.length && !movedOrder; ri++) {
const r = zone.riders[ri];
if (!Array.isArray(r.orders)) continue;
const oi = r.orders.findIndex((o) => String(o.orderid) === String(orderId));
if (oi !== -1) {
movedOrder = r.orders[oi];
r.orders.splice(oi, 1);
homeZoneIdx = zi;
// A rider left with zero orders after this move is a ghost entry —
// Dispatch's rider list renders every zone.riders[] entry
// unconditionally, so it would keep showing as a clickable
// 0-trips/0km/₹0 card with nothing inside once its last order is
// reassigned elsewhere. Drop it from the zone entirely.
if (r.orders.length === 0) {
zone.riders.splice(ri, 1);
}
}
}
}
if (movedOrder) {
const updated = {
...movedOrder,
rider_id: newRiderId,
userid: newRiderId,
rider_name: newRiderName,
rider: newRiderName
};
let placed = false;
for (const zone of next.zones) {
if (!Array.isArray(zone.riders)) continue;
const target = zone.riders.find(
(r) => String(r.rider_id ?? r.userid) === String(newRiderId)
);
if (target) {
target.orders = target.orders || [];
target.orders.push(updated);
placed = true;
break;
}
}
if (!placed && homeZoneIdx >= 0) {
next.zones[homeZoneIdx].riders.push({
rider_id: newRiderId,
userid: newRiderId,
rider_name: newRiderName,
orders: [updated]
});
}
}
}
return next;
};
// Merge a reconcile-API response { riders:[{rider_id, orders}] } back into
// dispatchPreviewData. Replaces each rider's orders[] in zones (preserving
// zone containment), then rebuilds the flat details list from the new tree.
export const applyReconcileResponse = (preview, response) => {
if (!preview || !Array.isArray(response?.riders)) return preview;
const next = JSON.parse(JSON.stringify(preview));
const newOrdersByRider = new Map(
response.riders.map((r) => [String(r.rider_id), r.orders || []])
);
if (Array.isArray(next.zones) && next.zones.length) {
// Pass 1: wipe every existing copy of a responding rider's orders across
// ALL zones. The server's reconciled list is the single source of truth,
// and a rider can be present in multiple zones (one per delivery suburb).
// The previous "update first match, delete from map" loop left stale
// copies in the other zones, which extractRiders then concatenated into
// duplicate orderids — surfacing as duplicate deliveries on Assign.
next.zones.forEach((zone) => {
if (!Array.isArray(zone.riders)) return;
zone.riders.forEach((r) => {
const key = String(r.rider_id ?? r.userid);
if (newOrdersByRider.has(key)) r.orders = [];
});
});
// Pass 2: drop the reconciled orders onto the first zone that already
// lists the rider. If the rider isn't anywhere in the tree, append a
// fresh rider entry to zone[0].
newOrdersByRider.forEach((orders, riderKey) => {
let placed = false;
for (const zone of next.zones) {
if (!Array.isArray(zone.riders)) continue;
const target = zone.riders.find(
(r) => String(r.rider_id ?? r.userid) === riderKey
);
if (target) {
target.orders = orders;
placed = true;
break;
}
}
if (!placed) {
const target = next.zones[0];
target.riders = target.riders || [];
target.riders.push({
rider_id: Number(riderKey) || riderKey,
rider_name: orders[0]?.rider_name || `Rider ${riderKey}`,
orders
});
}
});
// Same ghost-rider cleanup as moveOrderInPreviewData: if the reconcile
// response came back with an empty orders[] for a rider (every stop it
// had got reassigned elsewhere during reconciliation), don't leave that
// rider sitting in the tree as a 0-trips/0km/₹0 card with nothing inside.
next.zones.forEach((zone) => {
if (!Array.isArray(zone.riders)) return;
zone.riders = zone.riders.filter((r) => Array.isArray(r.orders) && r.orders.length > 0);
});
} else {
next.zones = [
{
zone_name: 'Reconciled',
riders: response.riders.map((r) => ({
rider_id: r.rider_id,
rider_name: r.rider_name || `Rider ${r.rider_id}`,
orders: r.orders || []
}))
}
];
}
// Rebuild flat details from the updated zones->riders->orders tree.
const flatDetails = [];
next.zones.forEach((zone) => {
(zone.riders || []).forEach((r) => {
(r.orders || []).forEach((o) => {
flatDetails.push({
...o,
rider_id: r.rider_id,
userid: r.rider_id,
rider_name: r.rider_name,
rider: r.rider_name
});
});
});
});
next.details = flatDetails;
return next;
};
// The delivery amount, recomputed from the rate card the solver echoed back.
// Applied at render time so the commit payload always reflects the current
// state of the tree without a synchronising effect.
export function computeDeliveryAmounts(list) {
return list.map((item) => {
const cumulativeKms = Number(item.cumulativekms || 0);
const minKm = Number(item.minkm || 0);
const basePrice = Number(item.baseprice || 0);
const pricePerKm = Number(item.priceperkm || 0);
if (cumulativeKms <= minKm) return { ...item, deliveryamt: basePrice };
return { ...item, deliveryamt: (cumulativeKms - minKm) * pricePerKm + basePrice };
});
}

442
src/lib/dispatchShared.js Normal file
View File

@@ -0,0 +1,442 @@
// Shared constants and pure helpers for the Dispatch board and its
// sub-components. Lives outside the page so the host component and the child
// views cannot form a circular import.
//
// Ported from the Express Console's `dispatch/dispatchShared.js`. The geometry
// and the Kalman/RTS smoother are carried over verbatim — their tuning was
// arrived at against real two-wheeler GPS traces, and re-deriving it would be
// guesswork dressed up as a rewrite.
import { useState, useEffect } from 'react';
// Replaces MUI's `useMediaQuery(theme.breakpoints.down('md'))`, which was the
// last MUI dependency in Preview.js and CompareDataPanel.js. MUI's `md`
// breakpoint is 900px and `down()` is exclusive, so this is the identical
// `max-width: 899.95px` query it generated — the same width the scoped media
// blocks in Dispatch.css already key off.
const NARROW_VIEWPORT_QUERY = '(max-width: 899.95px)';
export function useIsNarrowViewport() {
const [isNarrow, setIsNarrow] = useState(
() => typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia(NARROW_VIEWPORT_QUERY).matches
);
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return undefined;
const mql = window.matchMedia(NARROW_VIEWPORT_QUERY);
const onChange = (e) => setIsNarrow(e.matches);
// Re-sync on mount in case the viewport changed between the lazy initial
// state and the effect running.
setIsNarrow(mql.matches);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, []);
return isNarrow;
}
// Enter/Space activation for div/li surfaces that carry an onClick.
//
// The dispatch UI is built from clickable cards and list rows that wrap
// headings, badges and bars, so they can't become real <button>s (interactive
// content nested in a button is invalid markup, and it would fight the card
// CSS). `role="button"` + `tabIndex={0}` + this handler is the sanctioned
// shape. Pair all three — a role without a tabIndex is not reachable, and a
// click without a key handler is mouse-only.
export const onActivate = (handler) => (e) => {
if (!handler) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handler(e);
}
};
// Status colour for the places that need a RAW HEX rather than a rendered
// badge — Leaflet divIcon HTML built as a template string, and the `style`
// attribute on a few inline pills. Anything that renders normal JSX should use
// <StatusBadge status={...}/> instead; this exists only because you cannot put
// a React component inside an `L.divIcon({ html: '...' })` string.
//
// This used to be a private STATUS_STYLES table, and it had drifted badly from
// the canonical one in themes/dt/status.js:
//
// picked #0ea5e9 → #8b5cf6 (was identical to `active`, so two
// active #0ea5e9 → #14b8a6 different states looked the same)
// accepted #8b5cf6 → #6366f1 (was identical to the new `picked`)
// arrived #ea580c → #06b6d4
// delivered #22c55e → #10b981
// skipped #94a3b8 → #f97316 (was the same grey as "unknown")
//
// So the dispatch map painted a picked-up order and an in-transit order the
// same blue, while the deliveries table painted them purple and teal. Now both
// read from one table. `getStatusMeta` also resolves the raw backend enums
// (miler_assigned, converted_to_consignment, …), which this never did — those
// previously fell through to the grey "unknown" branch.
//
// The `{ label, bg, fg }` shape is kept so the existing call sites are
// unchanged; `fg` is always white, as it was in every entry of the old table.
/**
* A status as `{ label, bg, fg }` for a Leaflet `divIcon`.
*
* This is the ONE sanctioned reason a status colour is read as a raw hex
* instead of rendered through `<StatusBadge>`: a divIcon is built from an HTML
* template string, and a React component cannot go inside one. Anywhere that
* renders normal JSX must use the badge, or the map and the tables drift into
* painting the same state two different colours — which is exactly what
* happened before this became a single table.
*/
const MARKER_COLOR = {
pending: '#94a3b8',
pending_pickup: '#94a3b8',
miler_assigned: '#6366f1',
accepted: '#6366f1',
pickup_scheduled: '#6366f1',
arrived: '#06b6d4',
picked: '#8b5cf6',
converted_to_consignment: '#8b5cf6',
active: '#14b8a6',
out_for_delivery: '#14b8a6',
delivered: '#10b981',
skipped: '#f97316',
cancelled: '#ef4444',
canceled: '#ef4444'
};
export const getStatusStyle = (status) => {
const key = String(status || '').toLowerCase();
const label = key ? key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) : 'Unknown';
return { label, bg: MARKER_COLOR[key] || '#94a3b8', fg: '#fff' };
};
// Order-status sets used for completion / skipped decisions across the
// rider list, the planned-route renderer, and the compare data panel.
export const FINAL_STATUSES = new Set(['delivered']);
export const SKIPPED_STATUSES = new Set(['cancelled', 'skipped']);
// Per-step palette — wider and more deliberately spaced than the rider
// palette so a 10-stop day reads as 10 distinct colors on the compare
// map's polylines + pins.
export const STEP_PALETTE = [
'#2563eb', // blue-600
'#dc2626', // red-600
'#16a34a', // green-600
'#ea580c', // orange-600
'#9333ea', // purple-600
'#0891b2', // cyan-600
'#ca8a04', // yellow-600
'#db2777', // pink-600
'#0f766e', // teal-700
'#7c3aed', // violet-600
'#65a30d', // lime-600
'#0284c7', // sky-600
'#b91c1c', // red-700
'#15803d', // green-700
'#a16207', // yellow-700
'#86198f' // fuchsia-800
];
export const stepColor = (i) =>
STEP_PALETTE[((i % STEP_PALETTE.length) + STEP_PALETTE.length) % STEP_PALETTE.length];
// Pure helper — converts 1, 2, 3, 21 → "1st", "2nd", "3rd", "21st". Used
// by the compare data panel for the route-sequence diff list ("Visited
// 4th · planned 2nd").
export const ordinal = (n) => {
if (n == null) return '';
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]);
};
// An order is "active" (currently in progress) when it's neither completed
// (delivered) nor skipped/cancelled. The Active view uses this to collapse a
// rider down to the single delivery they're working on right now.
export const isActiveDelivery = (o) => {
const s = String(o?.orderstatus || '').toLowerCase();
return s === 'active';
};
// A rider's single in-progress delivery: the first non-final, non-skipped
// stop in (trip, step) order. Returns null when the rider has nothing active
// (everything delivered/cancelled, or GPS-only with no orders).
export const getActiveOrder = (orders) => {
if (!Array.isArray(orders) || !orders.length) return null;
const sorted = [...orders].sort((a, b) => {
const tA = a.trip_number || 1;
const tB = b.trip_number || 1;
if (tA !== tB) return tA - tB;
return (a.step || 0) - (b.step || 0);
});
return sorted.find(isActiveDelivery) || null;
};
// Haversine distance between two [lat, lng] points in kilometers. Good to
// ~0.1% across city scales; we use it to sum the length of an OSRM-snapped
// polyline so the Compare delta panel can show "actual km" without depending
// on the backend's actualkms field (which can be stale or missing). Also
// reused by deliveries.js's Update Status dialog to compute a real Actual
// KMs figure from GET /admin/consignments/:id/logs — see polylineLengthKm.
export function haversineKm(a, b) {
const R = 6371; // km
const toRad = (d) => (d * Math.PI) / 180;
const lat1 = toRad(a[0]);
const lat2 = toRad(b[0]);
const dLat = toRad(b[0] - a[0]);
const dLon = toRad(b[1] - a[1]);
const s = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(s)));
}
export function polylineLengthKm(points) {
if (!Array.isArray(points) || points.length < 2) return 0;
let total = 0;
for (let i = 1; i < points.length; i++) {
total += haversineKm(points[i - 1], points[i]);
}
return total;
}
// ─── Kalman filter + RTS smoother for GPS pings ──────────────────────────
//
// Two independent 1D Kalman filters (one for lat, one for lng) applied to a
// chronologically sorted list of GPS pings, followed by a Rauch-Tung-
// Striebel backward pass. Per-axis state: [position, velocity]. Constant-
// velocity dynamics with random acceleration as process noise; measurement
// model H = [1, 0] (we measure position only).
//
// Pipeline:
// 1. Pre-filter teleport pings (>maxSpeedKmh between consecutive pings,
// e.g. cold-start fix, GPS multipath). These would otherwise tug the
// forward filter even with the in-loop Mahalanobis gate enabled.
// 2. Forward Kalman pass with Mahalanobis 3σ outlier gating — pings whose
// innovation exceeds the gate are not used to update; the prediction
// is kept as the posterior. Stores prior + posterior moments at each
// step so the backward pass can run.
// 3. Backward RTS smoother — refines every step using ALL future
// observations. Logs are fetched in one shot (not streamed) so we
// can afford the second pass; the accuracy lift is biggest near the
// start of the trail and through turns the forward pass under-corrects.
//
// Tuning (all in degrees² since pings are in lat/lng):
// processNoise (q) — random-acceleration variance (deg²/s²). Default
// tuned for urban two-wheelers (~1 m/s² accel).
// Lower = smoother but slower to follow sharp turns.
// measurementNoise (r) — GPS-fix variance (deg²). Default = ~5 m std dev,
// which matches consumer GPS in open urban areas.
// Bump for dense canyons.
// outlierGate — Mahalanobis² threshold for in-loop rejection.
// 9.0 = 3σ (≈ 99.7% of inliers pass).
// maxSpeedKmh — pre-filter for impossible inter-ping speed.
// 120 km/h covers any legal two-wheeler movement
// plus margin; anything above is GPS error.
export function kalmanSmoothGps(pings, options = {}) {
if (!Array.isArray(pings) || pings.length === 0) return [];
// 1. Filter out obviously invalid coordinate pings (e.g. 0,0 or NaN)
const cleanedPings = pings.filter(p =>
Number.isFinite(p.lat) &&
Number.isFinite(p.lng) &&
(Math.abs(p.lat) > 0.1 || Math.abs(p.lng) > 0.1)
);
if (cleanedPings.length === 0) return [];
if (cleanedPings.length === 1) {
return [{ lat: cleanedPings[0].lat, lng: cleanedPings[0].lng, logdate: cleanedPings[0].logdate, _ts: cleanedPings[0]._ts }];
}
const processNoise =
options.processNoise != null ? options.processNoise : 1e-10;
const measurementNoise =
options.measurementNoise != null ? options.measurementNoise : 2e-9;
const outlierGate =
options.outlierGate != null ? options.outlierGate : 9.0;
const maxSpeedKmh =
options.maxSpeedKmh != null ? options.maxSpeedKmh : 120;
const tsOf = (p) =>
p._ts || (p.logdate ? new Date(p.logdate).getTime() : 0);
// 2. Scan forward to find the first valid starting anchor
let startIdx = 0;
while (startIdx < cleanedPings.length - 1) {
const p0 = cleanedPings[startIdx];
const p1 = cleanedPings[startIdx + 1];
const ts0 = tsOf(p0);
const ts1 = tsOf(p1) || ts0 + 1000;
const dtSec = Math.max(0.001, (ts1 - ts0) / 1000);
const km = haversineKm([p0.lat, p0.lng], [p1.lat, p1.lng]);
const speedKmh = (km / dtSec) * 3600;
if (speedKmh <= maxSpeedKmh) {
break;
} else {
// Speed is too high. Check if p1->p2 is normal (meaning p0 is the outlier)
if (startIdx + 2 < cleanedPings.length) {
const p2 = cleanedPings[startIdx + 2];
const ts2 = tsOf(p2) || ts1 + 1000;
const dtSec12 = Math.max(0.001, (ts2 - ts1) / 1000);
const km12 = haversineKm([p1.lat, p1.lng], [p2.lat, p2.lng]);
const speedKmh12 = (km12 / dtSec12) * 3600;
if (speedKmh12 <= maxSpeedKmh) {
startIdx = startIdx + 1;
continue;
}
}
startIdx++;
}
}
// 3. Teleport filter starting from the valid anchor
const accepted = [cleanedPings[startIdx]];
let lastTs = tsOf(cleanedPings[startIdx]);
for (let i = startIdx + 1; i < cleanedPings.length; i++) {
const p = cleanedPings[i];
const ts = tsOf(p) || lastTs + 1000;
const dtSec = Math.max(0.001, (ts - lastTs) / 1000);
const prev = accepted[accepted.length - 1];
const km = haversineKm([prev.lat, prev.lng], [p.lat, p.lng]);
const speedKmh = (km / dtSec) * 3600;
if (speedKmh > maxSpeedKmh) continue;
accepted.push(p);
lastTs = ts;
}
if (accepted.length < 2) {
return accepted.map((p) => ({ lat: p.lat, lng: p.lng, logdate: p.logdate, _ts: p._ts }));
}
// Run a 1D Kalman + RTS smoother over one axis. Returns smoothed
// positions parallel to `accepted`.
const smoothAxis = (axisKey) => {
const N = accepted.length;
// Per-step storage for the backward RTS pass.
const xPost = new Array(N); // [pos, vel] posterior after update
const pPost = new Array(N); // 2x2 cov posterior, flattened [p00,p01,p10,p11]
const xPrior = new Array(N); // predicted mean before update
const pPrior = new Array(N); // predicted cov before update
const dtArr = new Array(N); // dt from i-1 → i, for RTS transition
// Initial state: position = first measurement, velocity from the first
// two pings (better than 0 — keeps the start of the trail from lagging
// behind the rider's actual motion). Initial position covariance = r
// (we just measured it); initial velocity covariance is loose so it
// can be refined quickly.
const ts0 = tsOf(accepted[0]);
const ts1 = tsOf(accepted[1]);
const dt01 = Math.max(0.1, (ts1 - ts0) / 1000);
const v0 = (accepted[1][axisKey] - accepted[0][axisKey]) / dt01;
xPost[0] = [accepted[0][axisKey], v0];
pPost[0] = [measurementNoise, 0, 0, 1];
xPrior[0] = xPost[0].slice();
pPrior[0] = pPost[0].slice();
dtArr[0] = 0;
let prevTs = ts0;
for (let i = 1; i < N; i++) {
const ts = tsOf(accepted[i]) || prevTs + 1000;
const dt = Math.max(0.1, (ts - prevTs) / 1000);
prevTs = ts;
dtArr[i] = dt;
// ─── Predict ───
// x' = F x where F = [[1, dt], [0, 1]]
const [xPrev, vPrev] = xPost[i - 1];
const xPredPos = xPrev + vPrev * dt;
const xPredVel = vPrev;
// P' = F P F^T + Q where Q = q · [[dt⁴/4, dt³/2], [dt³/2, dt²]]
const [pp00, pp01, pp10, pp11] = pPost[i - 1];
const dt2 = dt * dt;
const dt3 = dt2 * dt;
const dt4 = dt3 * dt;
const np00 = pp00 + dt * (pp01 + pp10) + dt2 * pp11 + (dt4 / 4) * processNoise;
const np01 = pp01 + dt * pp11 + (dt3 / 2) * processNoise;
const np10 = pp10 + dt * pp11 + (dt3 / 2) * processNoise;
const np11 = pp11 + dt2 * processNoise;
xPrior[i] = [xPredPos, xPredVel];
pPrior[i] = [np00, np01, np10, np11];
// ─── Update (with Mahalanobis gating) ───
// y = z − Hx' (innovation)
// S = H P' H^T + R (innovation covariance)
// Reject the measurement if mahal² = y²/S exceeds the gate. The
// prediction then carries forward as the posterior — the trail stays
// continuous instead of being yanked toward a bad fix.
const z = accepted[i][axisKey];
const y = z - xPredPos;
const S = np00 + measurementNoise;
const mahal2 = (y * y) / S;
if (mahal2 > outlierGate) {
xPost[i] = [xPredPos, xPredVel];
pPost[i] = [np00, np01, np10, np11];
continue;
}
// K = P' H^T / S
const K0 = np00 / S;
const K1 = np10 / S;
// x = x' + K y
const newPos = xPredPos + K0 * y;
const newVel = xPredVel + K1 * y;
// P = (I − K H) P'
xPost[i] = [newPos, newVel];
pPost[i] = [
(1 - K0) * np00,
(1 - K0) * np01,
np10 - K1 * np00,
np11 - K1 * np01
];
}
// ─── Backward RTS smoother ─────────────────────────────────────────
// x_smooth[N-1] = x_post[N-1]
// For i = N-2 … 0:
// C = P_post[i] · F^T · inv(P_prior[i+1])
// x_smooth[i] = x_post[i] + C · (x_smooth[i+1] − x_prior[i+1])
// F^T for a constant-velocity model is [[1,0],[dt,1]], so
// P_post · F^T = [[p00 + dt·p01, p01],
// [p10 + dt·p11, p11]]
const xSmooth = new Array(N);
xSmooth[N - 1] = xPost[N - 1].slice();
for (let i = N - 2; i >= 0; i--) {
const dt = dtArr[i + 1];
const [pp00, pp01, pp10, pp11] = pPost[i];
const a = pp00 + dt * pp01;
const b = pp01;
const c = pp10 + dt * pp11;
const d = pp11;
// Invert P_prior[i+1] (2x2): inv = (1/det) · [[q11,-q01],[-q10,q00]]
const [q00, q01, q10, q11] = pPrior[i + 1];
const det = q00 * q11 - q01 * q10;
if (!Number.isFinite(det) || Math.abs(det) < 1e-30) {
xSmooth[i] = xPost[i].slice();
continue;
}
const inv00 = q11 / det;
const inv01 = -q01 / det;
const inv10 = -q10 / det;
const inv11 = q00 / det;
// Smoother gain C = (P_post · F^T) · inv(P_prior_next)
const c00 = a * inv00 + b * inv10;
const c01 = a * inv01 + b * inv11;
const c10 = c * inv00 + d * inv10;
const c11 = c * inv01 + d * inv11;
const dxPos = xSmooth[i + 1][0] - xPrior[i + 1][0];
const dxVel = xSmooth[i + 1][1] - xPrior[i + 1][1];
xSmooth[i] = [
xPost[i][0] + c00 * dxPos + c01 * dxVel,
xPost[i][1] + c10 * dxPos + c11 * dxVel
];
}
return xSmooth.map((s) => s[0]);
};
const lats = smoothAxis('lat');
const lngs = smoothAxis('lng');
return accepted.map((p, i) => ({
lat: lats[i],
lng: lngs[i],
logdate: p.logdate,
_ts: p._ts
}));
}

78
src/lib/distance.js Normal file
View File

@@ -0,0 +1,78 @@
/**
* Calculates distance (in km) between origin and destination.
* Uses OSRM as primary driving distance source, and falls back to Haversine with a 1.3 multiplier.
*
* @param {Object} origin - { latitude, longitude }
* @param {Object} destination - { latitude, longitude }
* @returns {Promise<number>} - Round distance in KM
*/
// Duration from the most recent OSRM route, in minutes, or null when the
// Haversine fallback was used (a straight-line estimate has no honest
// duration — better to show nothing than a made-up ETA).
let lastRouteDurationMin = null;
export const getLastRouteDurationMin = () => lastRouteDurationMin;
export const calculateDrivingDistance = async (origin, destination) => {
const lat1 = origin?.latitude;
const lon1 = origin?.longitude;
const lat2 = destination?.latitude;
const lon2 = destination?.longitude;
if (lat1 == null || lon1 == null || lat2 == null || lon2 == null) {
throw new Error("Invalid coordinates");
}
// 1. Try OSRM API (either self-hosted or public demo server for fallback)
const osrmBaseUrl = import.meta.env.VITE_OSRM_URL || "https://router.project-osrm.org";
try {
const url = `${osrmBaseUrl}/route/v1/driving/${lon1},${lat1};${lon2},${lat2}?overview=false`;
const response = await fetch(url);
if (response.ok) {
const data = await response.json();
if (data.routes && data.routes.length > 0) {
const distanceInMeters = data.routes[0].distance;
const distanceInKm = distanceInMeters / 1000;
// OSRM already returns the driving duration alongside the distance;
// it was being discarded. Cached here so callers that want an ETA get
// a REAL routed figure instead of dividing km by a guessed speed.
lastRouteDurationMin = Number.isFinite(data.routes[0].duration) ? Math.round(data.routes[0].duration / 60) : null;
return Math.round(distanceInKm);
}
}
} catch (error) {
console.warn("OSRM API failed, falling back to Haversine math:", error);
}
// 2. Fallback: Haversine Formula with 1.3x multiplier
const toRad = (val) => (val * Math.PI) / 180;
const R = 6371; // Earth radius in km
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const aerialDistance = R * c;
// No route was fetched, so there is no honest duration to report.
lastRouteDurationMin = null;
return Math.round(aerialDistance * 1.3);
};
/**
* Calculates total charge based on distance and pricing tier
*
* @param {number} distanceKm
* @param {number} basePrice
* @param {number} pricePerKm
* @param {number} minKm
* @returns {number}
*/
export const calculateTotalCharge = (distanceKm, basePrice, pricePerKm, minKm) => {
if (distanceKm < minKm) {
return basePrice;
}
return (distanceKm - minKm) * pricePerKm + basePrice;
};

102
src/lib/doormileFormat.js Normal file
View File

@@ -0,0 +1,102 @@
import * as React from 'react';
/**
* Formatting and small data helpers shared by the console pages.
*
* These live here rather than in each page because a number that reads ₹1,240
* on Orders and 1240 on Reports looks like two different figures for the same
* money, and an operator reconciling the two has no way to tell that they agree.
*/
const INR = new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR',
maximumFractionDigits: 2,
});
const DECIMAL = new Intl.NumberFormat('en-IN');
/** Money, or a dash. `0` is a real amount and renders as ₹0.00. */
export const currency = (value, fallback = '—') => {
const n = Number(value);
return Number.isFinite(n) ? INR.format(n) : fallback;
};
/** A count with thousands separators, or a dash. */
export const number = (value, fallback = '—') => {
const n = Number(value);
return Number.isFinite(n) ? DECIMAL.format(n) : fallback;
};
/** A distance in km to one decimal, or a dash. */
export const km = (value, fallback = '—') => {
const n = Number(value);
return Number.isFinite(n) ? `${n.toFixed(1)} km` : fallback;
};
/** `value` as a whole-number percentage of `total`, guarding divide-by-zero. */
export const percentOf = (value, total) => {
const v = Number(value);
const t = Number(total);
if (!Number.isFinite(v) || !Number.isFinite(t) || t === 0) return 0;
return Math.round((v / t) * 100);
};
/** Any blank-ish value rendered as an em dash rather than an empty cell. */
export const orDash = (value) => {
if (value === null || value === undefined) return '—';
const text = String(value).trim();
return text === '' ? '—' : text;
};
/** Case-insensitive substring match of `query` across the given fields. */
export const matchesQuery = (row, fields, query) => {
const q = String(query || '').trim().toLowerCase();
if (!q) return true;
return fields
.map((field) => (typeof field === 'function' ? field(row) : row?.[field]))
.filter((value) => value !== null && value !== undefined && value !== '')
.some((value) => String(value).toLowerCase().includes(q));
};
/**
* A value that settles after the user stops typing.
*
* Filtering a few thousand rows on every keystroke is the difference between a
* search box that types smoothly and one that drops characters.
*/
export function useDebouncedValue(value, delay = 250) {
const [debounced, setDebounced] = React.useState(value);
React.useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
/**
* Downloads `rows` as an .xlsx file.
*
* `columns` is `[{ key, header, value? }]` — the same declaration shape the
* tables use, so an export and the table it came from cannot drift apart.
* SheetJS is imported on demand: it is the single largest dependency in the
* console and most sessions never export anything.
*/
export const exportRows = async (rows, columns, filename) => {
const XLSX = await import('xlsx');
const data = (rows || []).map((row) => {
const record = {};
columns.forEach((column) => {
const raw = column.value ? column.value(row) : row?.[column.key];
record[column.header] = raw === null || raw === undefined ? '' : raw;
});
return record;
});
const sheet = XLSX.utils.json_to_sheet(data);
const book = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(book, sheet, 'Export');
XLSX.writeFile(book, `${filename}.xlsx`);
};

771
src/lib/doormileHooks.js Normal file
View File

@@ -0,0 +1,771 @@
import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
import { kalmanSmoothGps } from '@/lib/dispatchShared';
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
import * as api from '@/api/doormile';
import { OpenToast, messageOf } from '@/api/doormile/notify';
/**
* React Query hooks over the Doormile Express admin API.
*
* Every page reads through here rather than calling `@/api/doormile` directly,
* so a resource has exactly one cache key across the app and a write on one
* page invalidates the list another page is showing. The keys are declared once
* in `KEYS` for the same reason — a hand-written key that drifts by a character
* is a cache that silently never invalidates.
*/
export const KEYS = {
dashboard: ['doormile', 'dashboard'],
profile: ['doormile', 'profile'],
reports: (from, to, tenantid, locationid, hubid) => ['doormile', 'reports', from, to, tenantid, locationid, hubid],
locationsSummary: (tenantid, locationid, from, to) => ['doormile', 'locations-summary', tenantid, locationid, from, to],
appUsers: ['doormile', 'app-users'],
partners: ['doormile', 'partners'],
partner: (id) => ['doormile', 'partner', id],
tenants: ['doormile', 'tenants'],
tenant: (id) => ['doormile', 'tenant', id],
tenantLocations: (tenantId) => ['doormile', 'tenant-locations', tenantId],
tenantCustomers: ['doormile', 'tenant-customers'],
tenantCustomer: (id) => ['doormile', 'tenant-customer', id],
customers: ['doormile', 'customers'],
hubs: ['doormile', 'hubs'],
hub: (id) => ['doormile', 'hub', id],
vehicles: ['doormile', 'vehicles'],
vehicle: (id) => ['doormile', 'vehicle', id],
milers: ['doormile', 'milers'],
miler: (id) => ['doormile', 'miler', id],
milerSummary: (hubid, from, to) => ['doormile', 'miler-summary', hubid, from, to],
milerLogs: (id, from, to) => ['doormile', 'miler-logs', id, from, to],
milerActivity: (id, from, to) => ['doormile', 'miler-activity', id, from, to],
riderSummaryCounts: ['doormile', 'rider-summary-counts'],
bookings: (pageno, pagesize) => ['doormile', 'bookings', pageno, pagesize],
booking: (id) => ['doormile', 'booking', id],
bookingTrack: (id) => ['doormile', 'booking-track', id],
consignments: ['doormile', 'consignments'],
consignment: (id) => ['doormile', 'consignment', id],
consignmentLogs: (id) => ['doormile', 'consignment-logs', id],
deliveries: (status, from, to, pageSize) => ['doormile', 'deliveries', status, from, to, pageSize],
deliveryCounts: ['doormile', 'delivery-counts'],
tripsheets: ['doormile', 'tripsheets'],
tripsheet: (id) => ['doormile', 'tripsheet', id],
pricing: ['doormile', 'pricing'],
doormilePricing: ['doormile', 'doormile-pricing'],
exceptions: ['doormile', 'exceptions'],
exception: (id) => ['doormile', 'exception', id],
competitorBranches: (page, limit) => ['doormile', 'competitor-branches', page, limit],
carrierPricing: (page, limit) => ['doormile', 'carrier-pricing', page, limit],
appLocations: ['doormile', 'app-locations'],
};
/* ── Shared helpers ───────────────────────────────────────────────────────── */
/**
* A mutation that toasts on both outcomes and invalidates the keys it touched.
*
* The Doormile API answers `{ success: false, message }` with HTTP 200 on some
* write paths, so "resolved" is not the same as "worked" — a mutation that only
* watched for a thrown error would report a refused write as a success.
*/
function useDoormileMutation({ mutationFn, invalidates = [], successMessage, onDone }) {
const queryClient = useQueryClient();
return useMutation({
mutationFn,
onSuccess: (result) => {
if (result && result.success === false) {
OpenToast(result.message || 'The change was refused', 'warning', 4000);
return;
}
invalidates.forEach((key) => queryClient.invalidateQueries({ queryKey: key }));
if (successMessage) OpenToast(successMessage, 'success');
onDone?.(result);
},
onError: (err) => OpenToast(messageOf(err), 'error', 4000),
});
}
/** Every key a booking write can invalidate — orders, deliveries and counts. */
const BOOKING_KEYS = [
['doormile', 'bookings'],
['doormile', 'deliveries'],
['doormile', 'delivery-counts'],
];
/* ── Dashboard, reports, profile ──────────────────────────────────────────── */
export const useDashboard = (options) =>
useQuery({ queryKey: KEYS.dashboard, queryFn: api.getDashboard, staleTime: 30_000, ...options });
export const useReports = (from, to, tenantid, locationid, hubid, options) =>
useQuery({
queryKey: KEYS.reports(from, to, tenantid, locationid, hubid),
queryFn: () => api.getReports(from, to, tenantid, locationid, hubid),
staleTime: 30_000,
...options,
});
export const useLocationsSummary = (tenantid, locationid, from, to, options) =>
useQuery({
queryKey: KEYS.locationsSummary(tenantid, locationid, from, to),
queryFn: () => api.getLocationsSummary(tenantid, locationid, from, to),
staleTime: 30_000,
...options,
});
export const useProfile = (options) =>
useQuery({ queryKey: KEYS.profile, queryFn: api.getusers, staleTime: 60_000, ...options });
export const useUpdatePassword = () =>
useDoormileMutation({
mutationFn: ({ current_password, new_password }) => api.updateProfilePassword(current_password, new_password),
successMessage: 'Password updated',
});
/* ── App users ────────────────────────────────────────────────────────────── */
export const useAppUsers = (options) =>
useQuery({ queryKey: KEYS.appUsers, queryFn: api.getAppUsers, staleTime: 30_000, ...options });
export const useCreateAppUser = () =>
useDoormileMutation({ mutationFn: api.createAppUser, invalidates: [KEYS.appUsers], successMessage: 'User created' });
export const useUpdateAppUser = () =>
useDoormileMutation({
mutationFn: ({ id, data }) => api.updateAppUser(id, data),
invalidates: [KEYS.appUsers],
successMessage: 'User updated',
});
export const useDeleteAppUser = () =>
useDoormileMutation({ mutationFn: api.deleteAppUser, invalidates: [KEYS.appUsers], successMessage: 'User removed' });
/* ── Partners (fleet suppliers) ───────────────────────────────────────────── */
export const usePartners = (options) =>
useQuery({ queryKey: KEYS.partners, queryFn: api.getPartners, staleTime: 60_000, ...options });
export const useCreatePartner = () =>
useDoormileMutation({ mutationFn: api.createPartner, invalidates: [KEYS.partners], successMessage: 'Partner created' });
export const useUpdatePartner = () =>
useDoormileMutation({
mutationFn: ({ id, data }) => api.updatePartner(id, data),
invalidates: [KEYS.partners],
successMessage: 'Partner updated',
});
export const useDeletePartner = () =>
useDoormileMutation({ mutationFn: api.deletePartner, invalidates: [KEYS.partners], successMessage: 'Partner removed' });
/* ── Tenants (client companies) ───────────────────────────────────────────── */
export const useTenants = (options) =>
useQuery({ queryKey: KEYS.tenants, queryFn: api.getalltenants, staleTime: 60_000, ...options });
export const useTenant = (id, options) =>
useQuery({ queryKey: KEYS.tenant(id), queryFn: () => api.getAdminTenant(id), enabled: !!id, ...options });
export const useCreateTenant = () =>
useDoormileMutation({ mutationFn: api.createAdminTenant, invalidates: [KEYS.tenants], successMessage: 'Client created' });
export const useUpdateTenant = () =>
useDoormileMutation({
mutationFn: ({ id, data }) => api.updateAdminTenant(id, data),
invalidates: [KEYS.tenants],
successMessage: 'Client updated',
});
export const useDeleteTenant = () =>
useDoormileMutation({ mutationFn: api.deleteAdminTenant, invalidates: [KEYS.tenants], successMessage: 'Client removed' });
export const useTenantLocations = (tenantId, options) =>
useQuery({
queryKey: KEYS.tenantLocations(tenantId),
queryFn: () => api.gettenantlocations(tenantId),
enabled: !!tenantId,
staleTime: 60_000,
...options,
});
export const useCreateTenantLocation = () =>
useDoormileMutation({
mutationFn: ({ tenantId, data }) => api.createTenantLocation(tenantId, data),
invalidates: [KEYS.tenants],
successMessage: 'Branch created',
});
export const useUpdateTenantLocation = () =>
useDoormileMutation({
mutationFn: ({ locationId, data }) => api.updateTenantLocation(locationId, data),
invalidates: [KEYS.tenants],
successMessage: 'Branch updated',
});
/* ── Customers ────────────────────────────────────────────────────────────── */
export const useCustomers = (options) =>
useQuery({ queryKey: KEYS.customers, queryFn: api.getAdminCustomers, staleTime: 60_000, ...options });
export const useCreateCustomer = () =>
useDoormileMutation({ mutationFn: api.createAdminCustomer, invalidates: [KEYS.customers], successMessage: 'Customer created' });
export const useUpdateCustomer = () =>
useDoormileMutation({
mutationFn: ({ id, data }) => api.updateAdminCustomer(id, data),
invalidates: [KEYS.customers],
successMessage: 'Customer updated',
});
export const useTenantCustomers = (options) =>
useQuery({ queryKey: KEYS.tenantCustomers, queryFn: api.getTenantCustomers, staleTime: 60_000, ...options });
export const useCreateTenantCustomer = () =>
useDoormileMutation({
mutationFn: api.createTenantCustomer,
invalidates: [KEYS.tenantCustomers],
successMessage: 'Customer created',
});
export const useUpdateTenantCustomer = () =>
useDoormileMutation({
mutationFn: ({ id, data }) => api.updateTenantCustomer(id, data),
invalidates: [KEYS.tenantCustomers],
successMessage: 'Customer updated',
});
export const useDeleteTenantCustomer = () =>
useDoormileMutation({
mutationFn: api.deleteTenantCustomer,
invalidates: [KEYS.tenantCustomers],
successMessage: 'Customer removed',
});
/* ── Hubs ─────────────────────────────────────────────────────────────────── */
export const useHubs = (options) =>
useQuery({ queryKey: KEYS.hubs, queryFn: api.getHubs, staleTime: 60_000, ...options });
export const useHub = (id, options) =>
useQuery({ queryKey: KEYS.hub(id), queryFn: () => api.getHub(id), enabled: !!id, ...options });
export const useCreateHub = () =>
useDoormileMutation({ mutationFn: api.createHub, invalidates: [KEYS.hubs], successMessage: 'Hub created' });
export const useUpdateHub = () =>
useDoormileMutation({
mutationFn: ({ id, data }) => api.updateHub(id, data),
invalidates: [KEYS.hubs],
successMessage: 'Hub updated',
});
export const useDeleteHub = () =>
useDoormileMutation({ mutationFn: api.deleteHub, invalidates: [KEYS.hubs], successMessage: 'Hub removed' });
/**
* The zone picker. There is no zones resource on this API — `applocationid`
* only exists as a field on a hub — so the list is derived from the distinct
* cities across `GET /admin/hubs`.
*/
export const useAppLocations = (options) =>
useQuery({ queryKey: KEYS.appLocations, queryFn: api.fetchAppLocations, staleTime: 300_000, ...options });
/* ── Vehicles ─────────────────────────────────────────────────────────────── */
export const useVehicles = (options) =>
useQuery({ queryKey: KEYS.vehicles, queryFn: api.getVehicles, staleTime: 60_000, ...options });
export const useCreateVehicle = () =>
useDoormileMutation({ mutationFn: api.createVehicle, invalidates: [KEYS.vehicles], successMessage: 'Vehicle created' });
export const useUpdateVehicle = () =>
useDoormileMutation({
mutationFn: ({ id, data }) => api.updateVehicle(id, data),
invalidates: [KEYS.vehicles],
successMessage: 'Vehicle updated',
});
export const useDeleteVehicle = () =>
useDoormileMutation({ mutationFn: api.deleteVehicle, invalidates: [KEYS.vehicles], successMessage: 'Vehicle removed' });
/* ── Milers (riders) ──────────────────────────────────────────────────────── */
export const useMilers = (options) =>
useQuery({ queryKey: KEYS.milers, queryFn: api.getallriders, staleTime: 30_000, ...options });
export const useMiler = (id, options) =>
useQuery({ queryKey: KEYS.miler(id), queryFn: () => api.getMiler(id), enabled: !!id, ...options });
/** Riders shaped for a dropdown — `label` is "name | phone". */
export const useRiderOptions = (options) =>
useQuery({ queryKey: [...KEYS.milers, 'options'], queryFn: api.fetchRidersList, staleTime: 30_000, ...options });
/** Total / active / available / on-delivery counts for the Riders KPI strip. */
export const useRiderSummaryCounts = (options) =>
useQuery({ queryKey: KEYS.riderSummaryCounts, queryFn: api.getallridersummary, staleTime: 30_000, ...options });
export const useMilerSummary = (hubid, from, to, options) =>
useQuery({
queryKey: KEYS.milerSummary(hubid, from, to),
queryFn: () => api.getMilerSummary(hubid, from, to),
staleTime: 30_000,
...options,
});
export const useMilerLogs = (id, from, to, limit, options) =>
useQuery({
queryKey: KEYS.milerLogs(id, from, to),
queryFn: () => api.getMilerLogs(id, from, to, limit),
enabled: !!id,
...options,
});
export const useMilerActivity = (id, from, to, options) =>
useQuery({
queryKey: KEYS.milerActivity(id, from, to),
queryFn: () => api.getMilerActivity(id, from, to),
enabled: !!id,
...options,
});
export const useCreateMiler = () =>
useDoormileMutation({ mutationFn: api.createMiler, invalidates: [KEYS.milers], successMessage: 'Rider created' });
export const useUpdateMiler = () =>
useDoormileMutation({
mutationFn: ({ id, data }) => api.updateMiler(id, data),
invalidates: [KEYS.milers],
successMessage: 'Rider updated',
});
export const useBlockMiler = () =>
useDoormileMutation({
mutationFn: ({ id, data }) => api.blockMiler(id, data),
invalidates: [KEYS.milers],
successMessage: 'Rider updated',
});
export const useAssignMilerVehicle = () =>
useDoormileMutation({
mutationFn: ({ id, data }) => api.assignMilerVehicle(id, data),
invalidates: [KEYS.milers, KEYS.vehicles],
successMessage: 'Vehicle assigned',
});
/* Notify takes a miler PROFILE id, not a userid and not an FCM token — the
server looks the device token up itself. */
export const useNotifyMiler = () =>
useDoormileMutation({
mutationFn: ({ milerProfileId, title, message }) => api.notifyRider(milerProfileId, title, message),
successMessage: 'Rider notified',
});
/* ── Bookings (orders) ────────────────────────────────────────────────────── */
/** One page of bookings, with the envelope's `total` kept for the pager. */
export const useBookingsPage = (pageno, pagesize, options) =>
useQuery({
queryKey: KEYS.bookings(pageno, pagesize),
queryFn: () => api.getBookingsPage(pageno, pagesize),
placeholderData: (previous) => previous,
staleTime: 15_000,
...options,
});
export const useBooking = (id, options) =>
useQuery({ queryKey: KEYS.booking(id), queryFn: () => api.getorderdetails(id), enabled: !!id, ...options });
export const useBookingTrack = (id, options) =>
useQuery({ queryKey: KEYS.bookingTrack(id), queryFn: () => api.getBookingTrack(id), enabled: !!id, ...options });
export const useCreateBooking = () =>
useDoormileMutation({ mutationFn: api.createExpressBooking, invalidates: BOOKING_KEYS, successMessage: 'Order created' });
export const useCreateBookingsBulk = () =>
useDoormileMutation({ mutationFn: api.createExpressBookingBulk, invalidates: BOOKING_KEYS });
/* assign-miler wants the miler's `userid` — a different identity space than the
`milerprofileid` every other /admin/milers route keys on. */
export const useAssignMilerToBooking = () =>
useDoormileMutation({
mutationFn: ({ id, mileruserid }) => api.assignMilerToBooking(id, { mileruserid }),
invalidates: BOOKING_KEYS,
successMessage: 'Rider assigned',
});
export const useBatchAssignBookings = () =>
useDoormileMutation({
mutationFn: ({ bookingIds, maxPerRider }) => api.batchAssignBookings(bookingIds, maxPerRider),
invalidates: BOOKING_KEYS,
});
export const useAssignVehicleToBooking = () =>
useDoormileMutation({
mutationFn: ({ id, data }) => api.assignVehicleToBooking(id, data),
invalidates: BOOKING_KEYS,
successMessage: 'Vehicle assigned',
});
export const useUpdateBookingStatus = () =>
useDoormileMutation({
mutationFn: ({ id, data }) => api.updateBookingStatus(id, data),
invalidates: BOOKING_KEYS,
successMessage: 'Status updated',
});
export const useCancelBooking = () =>
useDoormileMutation({
mutationFn: ({ id, reason }) => api.cancelBooking(id, { reason }),
invalidates: BOOKING_KEYS,
successMessage: 'Order cancelled',
});
export const useBulkCancelBookings = () =>
useDoormileMutation({
mutationFn: api.bulkCancelBookings,
invalidates: BOOKING_KEYS,
successMessage: 'Orders cancelled',
});
/* ── Deliveries ───────────────────────────────────────────────────────────── */
/**
* The Deliveries table — bookings that have moved past merely being created.
*
* Built from `/admin/bookings` rather than `/admin/consignments`, which has no
* documented response schema; see `fetchDeliveries` for the full reasoning and
* for which columns are deliberately left blank instead of invented.
*/
export const useDeliveries = ({ page = 1, pageSize = 50, status = '', from = '', to = '' } = {}, options) =>
useQuery({
queryKey: [...KEYS.deliveries(status, from, to, pageSize), page],
queryFn: () =>
api.fetchDeliveries({ pageParam: page, queryKey: ['deliveries', '', '', status, from, to, pageSize] }),
placeholderData: (previous) => previous,
staleTime: 15_000,
...options,
});
export const useDeliveryCounts = (options) =>
useQuery({ queryKey: KEYS.deliveryCounts, queryFn: api.fetchCountAPI, staleTime: 15_000, ...options });
export const useChangeDeliveryRider = () =>
useDoormileMutation({
mutationFn: ({ rider, row }) => api.changeRiderAPI(rider, row),
invalidates: BOOKING_KEYS,
successMessage: 'Rider changed',
});
/* Refuses statuses the consignment enum cannot express, with the reason — see
`updateDeliveryAPI`. Those come back as `{ success: false, message }`, which
`useDoormileMutation` surfaces as a warning rather than a false success. */
export const useUpdateDeliveryStatus = () =>
useDoormileMutation({ mutationFn: api.updateDeliveryAPI, invalidates: BOOKING_KEYS, successMessage: 'Status updated' });
export const useCancelDelivery = () =>
useDoormileMutation({
mutationFn: ({ row, reason }) => api.cancelDeliveryAPI(row, reason),
invalidates: BOOKING_KEYS,
successMessage: 'Delivery cancelled',
});
/* ── Consignments ─────────────────────────────────────────────────────────── */
export const useConsignments = (options) =>
useQuery({ queryKey: KEYS.consignments, queryFn: api.getConsignments, staleTime: 30_000, ...options });
export const useConsignment = (id, options) =>
useQuery({ queryKey: KEYS.consignment(id), queryFn: () => api.getConsignment(id), enabled: !!id, ...options });
export const useConsignmentLogs = (id, options) =>
useQuery({ queryKey: KEYS.consignmentLogs(id), queryFn: () => api.getConsignmentLogs(id), enabled: !!id, ...options });
/**
* The GPS trail for each of a rider's deliveries, as parallel queries.
*
* This is what the dispatch board's plan-vs-actual comparison is built on:
* `GET /admin/consignments/:id/logs` is the confirmed per-consignment trail,
* and the comparison is per stop, not per rider — so one query per delivery is
* the shape, not a single call.
*
* Pings come back in no guaranteed order and carry raw GPS jitter, so each
* trail is sorted chronologically and run through the Kalman/RTS smoother
* before it reaches a renderer. Without the sort a polyline zig-zags between
* consecutive points; without the smoother it wanders at every stop.
*
* A `deliveryid` on this board has never been confirmed to be a consignment id.
* If it is really a booking id the call 404s and the trail is simply empty —
* degraded, not broken, which is why the error is swallowed per query.
*/
export const useDeliveryTracks = (deliveryIds = [], enabled = true) =>
useQueries({
queries: deliveryIds.map((deliveryid) => ({
queryKey: KEYS.consignmentLogs(deliveryid),
queryFn: async () => {
let rows = [];
try {
rows = (await api.getConsignmentLogs(deliveryid)) || [];
if (!Array.isArray(rows)) rows = [];
} catch {
rows = [];
}
const sorted = rows
.map((row) => {
const at = row?.logdate ? parseDoormileTimestamp(row.logdate) : null;
return {
lat: parseFloat(row?.latitude ?? row?.lat),
lng: parseFloat(row?.longitude ?? row?.lng ?? row?.lon),
logdate: row?.logdate,
_ts: at && at.isValid() ? at.valueOf() : Number.MAX_SAFE_INTEGER,
};
})
.filter((point) => Number.isFinite(point.lat) && Number.isFinite(point.lng))
.sort((a, b) => a._ts - b._ts);
/* The smoother needs `_ts` to compute per-step dt, so it runs before
the timestamp is stripped on the way out. */
return kalmanSmoothGps(sorted).map(({ _ts, ...point }) => point);
},
enabled: enabled && deliveryid != null,
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
retry: 1,
})),
});
/**
* A rider's most recent periodic log — position, status, battery.
*
* Polled while the rider-info view is open, and only then: this is a live
* snapshot, and polling it from a background tab burns requests for a panel
* nobody is looking at.
*/
export const useRiderPeriodicLog = (milerProfileId, options) =>
useQuery({
queryKey: ['doormile', 'rider-periodic-log', milerProfileId],
queryFn: () => api.getRiderPeriodicLogs(milerProfileId),
enabled: !!milerProfileId,
...options,
});
/* ── Tripsheets ───────────────────────────────────────────────────────────── */
export const useTripsheets = (options) =>
useQuery({ queryKey: KEYS.tripsheets, queryFn: api.getTripsheets, staleTime: 30_000, ...options });
export const useTripsheet = (id, options) =>
useQuery({ queryKey: KEYS.tripsheet(id), queryFn: () => api.getTripsheet(id), enabled: !!id, ...options });
export const useCreateTripsheet = () =>
useDoormileMutation({ mutationFn: api.createTripsheet, invalidates: [KEYS.tripsheets], successMessage: 'Tripsheet created' });
export const useAddTripsheetItem = () =>
useDoormileMutation({
mutationFn: ({ id, consignmentid }) => api.addTripsheetItem(id, consignmentid),
invalidates: [KEYS.tripsheets],
successMessage: 'Consignment added',
});
export const useRemoveTripsheetItem = () =>
useDoormileMutation({
mutationFn: ({ id, itemId }) => api.removeTripsheetItem(id, itemId),
invalidates: [KEYS.tripsheets],
successMessage: 'Consignment removed',
});
export const useDispatchTripsheet = () =>
useDoormileMutation({
mutationFn: api.dispatchTripsheet,
invalidates: [KEYS.tripsheets],
successMessage: 'Tripsheet dispatched',
});
export const useArriveTripsheet = () =>
useDoormileMutation({
mutationFn: api.arriveTripsheet,
invalidates: [KEYS.tripsheets],
successMessage: 'Tripsheet marked arrived',
});
/* ── Pricing ──────────────────────────────────────────────────────────────── */
/* Two separate resources, deliberately: `/admin/pricing` is per-tenant client
pricing; `/admin/doormile-pricing` is Doormile's own internal banding and has
no tenant concept at all. */
export const usePricing = (options) =>
useQuery({ queryKey: KEYS.pricing, queryFn: api.getallpricing, staleTime: 60_000, ...options });
export const useCreatePricing = () =>
useDoormileMutation({ mutationFn: api.createAdminPricing, invalidates: [KEYS.pricing], successMessage: 'Pricing rule created' });
export const useUpdatePricing = () =>
useDoormileMutation({
mutationFn: ({ id, data }) => api.updateAdminPricing(id, data),
invalidates: [KEYS.pricing],
successMessage: 'Pricing rule updated',
});
export const useDeletePricing = () =>
useDoormileMutation({ mutationFn: api.deleteAdminPricing, invalidates: [KEYS.pricing], successMessage: 'Pricing rule removed' });
export const useSimulatePricing = () => useDoormileMutation({ mutationFn: api.simulatePricing });
export const useDoormilePricing = (options) =>
useQuery({ queryKey: KEYS.doormilePricing, queryFn: api.getDoormilePricing, staleTime: 60_000, ...options });
export const useCreateDoormilePricing = () =>
useDoormileMutation({
mutationFn: api.createDoormilePricing,
invalidates: [KEYS.doormilePricing],
successMessage: 'Band created',
});
export const useUpdateDoormilePricing = () =>
useDoormileMutation({
mutationFn: ({ id, data }) => api.updateDoormilePricing(id, data),
invalidates: [KEYS.doormilePricing],
successMessage: 'Band updated',
});
export const useDeleteDoormilePricing = () =>
useDoormileMutation({
mutationFn: api.deleteDoormilePricing,
invalidates: [KEYS.doormilePricing],
successMessage: 'Band removed',
});
/* ── Exceptions ───────────────────────────────────────────────────────────── */
export const useExceptions = (options) =>
useQuery({ queryKey: KEYS.exceptions, queryFn: api.getExceptions, staleTime: 30_000, ...options });
export const useException = (id, options) =>
useQuery({ queryKey: KEYS.exception(id), queryFn: () => api.getException(id), enabled: !!id, ...options });
export const useCreateException = () =>
useDoormileMutation({ mutationFn: api.createException, invalidates: [KEYS.exceptions], successMessage: 'Exception raised' });
export const useUpdateExceptionStatus = () =>
useDoormileMutation({
mutationFn: ({ id, status, resolution }) => api.updateExceptionStatus(id, status, resolution),
invalidates: [KEYS.exceptions],
successMessage: 'Exception updated',
});
/* ── Competitive intelligence ─────────────────────────────────────────────── */
/* These two resources paginate on page/limit, not the pageno/pagesize
convention used everywhere else, and return the whole envelope so the page
can build its own pager. A single fetch is NOT the full set. */
export const useCompetitorBranches = (page = 1, limit = 100, options) =>
useQuery({
queryKey: KEYS.competitorBranches(page, limit),
queryFn: () => api.getCompetitorBranches(page, limit),
placeholderData: (previous) => previous,
staleTime: 60_000,
...options,
});
export const useCreateCompetitorBranch = () =>
useDoormileMutation({
mutationFn: api.createCompetitorBranch,
invalidates: [['doormile', 'competitor-branches']],
successMessage: 'Branch added',
});
export const useUpdateCompetitorBranch = () =>
useDoormileMutation({
mutationFn: ({ id, data }) => api.updateCompetitorBranch(id, data),
invalidates: [['doormile', 'competitor-branches']],
successMessage: 'Branch updated',
});
export const useDeleteCompetitorBranch = () =>
useDoormileMutation({
mutationFn: api.deleteCompetitorBranch,
invalidates: [['doormile', 'competitor-branches']],
successMessage: 'Branch removed',
});
export const useCarrierPricing = (page = 1, limit = 100, options) =>
useQuery({
queryKey: KEYS.carrierPricing(page, limit),
queryFn: () => api.getCarrierPricing(page, limit),
placeholderData: (previous) => previous,
staleTime: 60_000,
...options,
});
export const useCreateCarrierPricing = () =>
useDoormileMutation({
mutationFn: api.createCarrierPricing,
invalidates: [['doormile', 'carrier-pricing']],
successMessage: 'Carrier rate added',
});
export const useUpdateCarrierPricing = () =>
useDoormileMutation({
mutationFn: ({ id, data }) => api.updateCarrierPricing(id, data),
invalidates: [['doormile', 'carrier-pricing']],
successMessage: 'Carrier rate updated',
});
export const useDeleteCarrierPricing = () =>
useDoormileMutation({
mutationFn: api.deleteCarrierPricing,
invalidates: [['doormile', 'carrier-pricing']],
successMessage: 'Carrier rate removed',
});
/* ── Reports ──────────────────────────────────────────────────────────────── */
export const useReportSummary = (appId, tenantid, locationid, startdate, enddate, options) =>
useQuery({
queryKey: ['doormile', 'report-summary', appId, tenantid, locationid, startdate, enddate],
queryFn: () => api.getreportsummary({ queryKey: [appId, tenantid, locationid, startdate, enddate] }),
staleTime: 30_000,
...options,
});
export const useReportLocationSummary = (appId, tenantid, locationid, startdate, enddate, options) =>
useQuery({
queryKey: ['doormile', 'report-location-summary', appId, tenantid, locationid, startdate, enddate],
queryFn: () => api.getreportlocationsummary({ queryKey: [appId, tenantid, locationid, startdate, enddate] }),
staleTime: 30_000,
...options,
});
export const useRidersSummary = (appId, startdate, enddate, options) =>
useQuery({
queryKey: ['doormile', 'riders-summary', appId, startdate, enddate],
queryFn: () => api.fetchRidersSummary({ queryKey: ['ridersSummary', appId, startdate, enddate] }),
staleTime: 30_000,
...options,
});
/** Live rider positions for the dispatch map. Polled, so it never toasts. */
export const useRiderLogs = (appId, options) =>
useQuery({
queryKey: ['doormile', 'rider-logs', appId],
queryFn: () => api.fetchRidersLogs({ queryKey: [appId] }),
...options,
});
export const useOrderPercentages = (startdate, enddate, options) =>
useQuery({
queryKey: ['doormile', 'order-percentages', startdate, enddate],
queryFn: () => api.fetchPercentageData({ queryKey: ['pct', '', startdate, enddate] }),
staleTime: 30_000,
...options,
});

View File

@@ -0,0 +1,33 @@
import dayjs from 'dayjs';
/**
* Doormile timestamps are IST (Asia/Kolkata) wall-clock stored in Postgres
* `timestamp without time zone` columns. Some responses come back with a
* trailing Z/offset anyway — a known Go+pgx footgun where a naive DB timestamp
* loads into `time.Time` under the UTC location and gets marshalled with a
* false "Z" suffix. dayjs reads a Z-suffixed string as a real UTC instant and
* converts it to the browser's local time, adding a spurious +5:30 on top of
* digits that were already correct IST.
*
* Stripping any trailing zone marker before parsing makes both cases — truly
* naive, or naive-with-false-Z — render and bucket identically as the raw
* wall-clock digits. Every page that displays or buckets a Doormile timestamp
* must go through here, or two pages will disagree about which day a row is on.
*/
export const parseDoormileTimestamp = (raw) => {
if (!raw) return dayjs(null);
const stripped = String(raw).replace(/(Z|[+-]\d{2}:?\d{2})$/, '');
return dayjs(stripped);
};
/** `raw` rendered in `format`, or `fallback` when the timestamp is unusable. */
export const formatDoormileTimestamp = (raw, format = 'DD MMM YYYY, hh:mm A', fallback = '—') => {
const parsed = parseDoormileTimestamp(raw);
return parsed.isValid() ? parsed.format(format) : fallback;
};
/** The calendar day (`YYYY-MM-DD`) a Doormile timestamp falls on. */
export const doormileDay = (raw) => {
const parsed = parseDoormileTimestamp(raw);
return parsed.isValid() ? parsed.format('YYYY-MM-DD') : undefined;
};

View File

@@ -1,315 +0,0 @@
/**
* The hiring record, derived once.
*
* Analytics and Hired History ask different questions of the same facts — "how
* is our hiring performing" and "who did we hire, and what happened" — and they
* answer them with different pages. What they must never do is *count*
* differently: a total on one page and the same total on the other have to be
* the same number, or the two pages stop being two views and become two claims.
*
* So the counting lives here, in plain functions over the collections the app
* already holds, and each page renders what it needs from the result. Nothing in
* this module knows what either page looks like.
*/
/** The mean of a numeric list, rounded. Zero for an empty list. */
export const avg = (xs) => {
const values = xs.filter((n) => Number.isFinite(n));
return values.length ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : 0;
};
/**
* Everyone hired, as one list.
*
* A Staff record is the hire; the application it came from carries how long it
* took and what it scored, and the posting carries the department. Joined here
* so neither page repeats the join — and so "department" means the same thing
* on both.
*/
export function buildHires({ staff = [], applications = [], postings = [] }) {
return staff.map((s) => {
const app = applications.find((a) => a.id === s.application_id);
const posting = postings.find((p) => p.id === s.job_posting_id);
const days = app
? Math.max(1, Math.round((new Date(app.updated_date).getTime() - new Date(app.created_date).getTime()) / 86400000))
: null;
return {
...s,
company: s.company || posting?.company || '—',
department: posting?.role_category || s.department || '—',
role: s.role || posting?.title || '—',
timeToHire: days || s.timeToHire || null,
score: s.ai_score || app?.ai_score || s.score || null,
profile_tier: s.profile_tier || 'skilled',
hire_date: s.hire_date || s.created_date || null,
applicationId: app?.id || s.application_id || null,
};
});
}
/**
* Demo fill, carried over from the page this module was extracted from.
*
* The store seeds three Staff records; Hired History has always padded that to
* eight so the page reads as a hiring history rather than as three rows. That
* padding is pre-existing product behaviour, not something derived — it is kept
* here, named for what it is, so both pages show what the page has always shown
* and there is one list to delete when the deployment has real volume.
*
* It only ever *adds* people the store does not already have, matched on email,
* so a real hire is never shadowed by a demo one.
*/
const DEMO_FILL = [
{ id: 's4', name: 'Sophia Chen', email: 'sophia.chen@email.com', role: 'Guest Relations Lead', department: 'Front Desk', profile_tier: 'expert', score: 90, timeToHire: 2, hire_date: '2026-07-22', status: 'hired' },
{ id: 's5', name: 'Oliver Bennett', email: 'oliver.b@email.com', role: 'Event Coordinator', department: 'Event Manager', profile_tier: 'skilled', score: 91, timeToHire: 3, hire_date: '2026-07-20', status: 'hired' },
{ id: 's6', name: 'Aaliyah Patel', email: 'aaliyah.p@email.com', role: 'Operations Supervisor', department: 'Housekeeping', profile_tier: 'solid', score: 87, timeToHire: 2, hire_date: '2026-07-18', status: 'hired' },
{ id: 's7', name: 'Lucas Wright', email: 'lucas.w@email.com', role: 'Concierge Lead', department: 'Front Desk', profile_tier: 'solid', score: 88, timeToHire: 2, hire_date: '2026-07-15', status: 'hired' },
{ id: 's8', name: 'Elena Rostova', email: 'elena.r@email.com', role: 'Lead Security Officer', department: 'Security', profile_tier: 'expert', score: 95, timeToHire: 1, hire_date: '2026-07-12', status: 'hired' },
];
/** The joined hires, with the demo fill applied for anyone not already on file. */
export function hiresWithFill(sources) {
const live = buildHires(sources);
const seen = new Set(live.map((h) => String(h.email || h.name).toLowerCase()));
const filled = [...live];
for (const person of DEMO_FILL) {
const key = String(person.email || person.name).toLowerCase();
if (!seen.has(key)) {
filled.push({ ...person, company: person.company || '—' });
seen.add(key);
}
}
return filled;
}
/** Headline figures: volume, speed, quality, and how many are still on. */
export function summarise(hires) {
return {
total: hires.length,
speed: avg(hires.map((h) => h.timeToHire)),
quality: avg(hires.map((h) => h.score)),
active: hires.filter((h) => h.status !== 'inactive').length,
onboarding: hires.filter((h) => h.status === 'onboarding').length,
};
}
/** The application stages this product counts, in the order they happen. */
export const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired'];
/**
* Applied → screened → shortlisted → interview → hired, with the pass-through
* rate and the loss at each step.
*
* Counted at-or-beyond, so a candidate who reached interview is counted as
* having been screened — a funnel that counts only the current status shows
* later stages as larger than earlier ones, which is not a funnel.
*/
export function buildFunnel(applications) {
const atOrBeyond = (stage) => {
const from = STAGE_ORDER.indexOf(stage);
return applications.filter((a) => STAGE_ORDER.indexOf(a.status) >= from).length;
};
const stages = [
{ key: 'applied', label: 'Applied', count: applications.length },
{ key: 'ai_screened', label: 'Screened', count: atOrBeyond('ai_screened') },
{ key: 'shortlisted', label: 'Shortlisted', count: atOrBeyond('shortlisted') },
{ key: 'interview', label: 'Interview', count: atOrBeyond('interview') },
{ key: 'hired', label: 'Hired', count: applications.filter((a) => a.status === 'hired').length },
];
const transitions = stages.slice(1).map((stage, i) => {
const previous = stages[i];
return {
from: previous.key,
to: stage.key,
rate: previous.count ? Math.round((stage.count / previous.count) * 100) : 0,
lost: Math.max(0, previous.count - stage.count),
};
});
/* The step losing the most people — the one worth acting on. */
const weakest = transitions.reduce(
(worst, t) => (!worst || t.rate < worst.rate ? t : worst),
null
);
return {
stages,
transitions,
weakestKey: weakest?.to || null,
conversion: applications.length
? Math.round((stages[4].count / applications.length) * 100)
: 0,
};
}
/** Cumulative hires by month — a trend needs a baseline, not a single bar. */
export function buildTrend(hires) {
const byMonth = new Map();
hires.filter((h) => h.hire_date).forEach((h) => {
const d = new Date(h.hire_date);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
byMonth.set(key, (byMonth.get(key) || 0) + 1);
});
let running = 0;
return [...byMonth.entries()].sort().map(([key, count]) => {
running += count;
const [y, m] = key.split('-');
return {
label: new Date(Number(y), Number(m) - 1).toLocaleDateString(undefined, { month: 'short' }),
hires: count,
cumulative: running,
};
});
}
/** Hires grouped by department, best-performing first. */
export function byDepartment(hires) {
const map = new Map();
hires.forEach((h) => {
const name = h.department && h.department !== '—' ? h.department : 'General';
const entry = map.get(name) || {
name, department: name, count: 0, scores: [], times: [], rolesSet: new Set(), hiresList: [],
};
entry.count += 1;
if (h.score) entry.scores.push(h.score);
if (h.timeToHire) entry.times.push(h.timeToHire);
if (h.role && h.role !== '—') entry.rolesSet.add(h.role);
entry.hiresList.push(h);
map.set(name, entry);
});
return [...map.values()]
.map((e) => ({
name: e.name,
department: e.department,
count: e.count,
scores: e.scores,
avgScore: avg(e.scores),
avgTimeToHire: avg(e.times),
roles: [...e.rolesSet],
hiresList: e.hiresList,
}))
.sort((a, b) => (b.avgScore || 0) - (a.avgScore || 0) || b.count - a.count);
}
/** Hires grouped by the role they were hired into, most-filled first. */
export function byPosition(hires) {
const map = new Map();
hires.forEach((h) => {
const key = h.role && h.role !== '—' ? h.role : 'Unspecified';
const entry = map.get(key) || { role: key, count: 0, scores: [], days: [], rated: 0, ratings: [] };
entry.count += 1;
if (h.score) entry.scores.push(h.score);
if (h.timeToHire) entry.days.push(h.timeToHire);
if (h.client_rating) { entry.rated += 1; entry.ratings.push(h.client_rating); }
map.set(key, entry);
});
return [...map.values()]
.map((e) => ({
role: e.role,
count: e.count,
avgScore: avg(e.scores),
avgDays: avg(e.days),
rated: e.rated,
avgRating: e.ratings.length
? Number((e.ratings.reduce((a, b) => a + b, 0) / e.ratings.length).toFixed(1))
: null,
}))
.sort((a, b) => b.count - a.count);
}
/**
* Where hiring is slow, and where it is fast.
*
* Velocity is only meaningful against something, so each role is measured
* against the workspace's own median rather than an industry figure nobody
* here can check.
*/
export function buildEfficiency(hires) {
const timed = hires.filter((h) => h.timeToHire);
if (!timed.length) return { median: 0, fastest: [], slowest: [], within48h: 0 };
const sorted = [...timed].sort((a, b) => a.timeToHire - b.timeToHire);
const median = sorted[Math.floor(sorted.length / 2)].timeToHire;
const roles = byPosition(timed).filter((r) => r.avgDays);
return {
median,
fastest: [...roles].sort((a, b) => a.avgDays - b.avgDays).slice(0, 4),
slowest: [...roles].sort((a, b) => b.avgDays - a.avgDays).slice(0, 4),
within48h: Math.round((timed.filter((h) => h.timeToHire <= 2).length / timed.length) * 100),
};
}
/**
* What the numbers say, as findings rather than figures.
*
* Every one is conditional on the data supporting it: a claim about the fastest
* department is only made when there is more than one department to compare, and
* a risk is only raised when something is actually at risk. A page that always
* shows three insights is showing decoration.
*/
export function buildInsights({ hires, departments, positions, funnel, efficiency }) {
const out = [];
const summary = summarise(hires);
if (departments.length > 1) {
const best = departments[0];
out.push({
tone: 'success',
title: `${best.department} is hiring the strongest candidates`,
body: `${best.count} hire${best.count === 1 ? '' : 's'} at an average score of ${best.avgScore}, against ${summary.quality} across the workspace.`,
});
}
if (funnel.weakestKey) {
const weak = funnel.transitions.find((t) => t.to === funnel.weakestKey);
const label = funnel.stages.find((s) => s.key === funnel.weakestKey)?.label;
if (weak && weak.lost > 0) {
out.push({
tone: 'warning',
title: `The largest drop-off is into ${label}`,
body: `${weak.rate}% pass through and ${weak.lost} candidate${weak.lost === 1 ? '' : 's'} stop there. It is the step with the most to recover.`,
});
}
}
if (efficiency.slowest.length && efficiency.median) {
const slow = efficiency.slowest[0];
if (slow.avgDays > efficiency.median) {
out.push({
tone: 'risk',
title: `${slow.role} takes longest to fill`,
body: `${slow.avgDays} days on average against a median of ${efficiency.median}. ${slow.count} hire${slow.count === 1 ? '' : 's'} on that record.`,
});
}
}
const unrated = positions.reduce((n, p) => n + (p.count - p.rated), 0);
if (unrated > 0) {
out.push({
tone: 'info',
title: `${unrated} hire${unrated === 1 ? '' : 's'} ${unrated === 1 ? 'has' : 'have'} no client review`,
body: 'Quality of hire is measured on the AI score alone until a review lands. Chasing these closes the loop on outcomes.',
});
}
if (funnel.conversion) {
out.push({
tone: 'info',
title: `${funnel.conversion}% of applicants are hired`,
body: `${funnel.stages[4].count} of ${funnel.stages[0].count} applications reached a hire.`,
});
}
return out;
}

View File

@@ -1,463 +0,0 @@
import { base44 } from '@/api/base44Client';
/**
* KROW AI Workflows — all powered by InvokeLLM.
* 1. AI Job Description Generator
* 2. AI Resume Builder
* 3. AI Candidate Screening
* 4. Live AI Voice Interview evaluation
*/
const SCREENING_SCHEMA = {
type: 'object',
properties: {
overall_score: { type: 'number', description: '0-100 weighted score' },
match_label: { type: 'string', description: 'Excellent Match | Good Match | Possible Fit | Not a Fit' },
summary: { type: 'string', description: '2-3 sentence summary of the candidate fit' },
strengths: { type: 'array', items: { type: 'string' } },
gaps: { type: 'array', items: { type: 'string' } },
recommendation: { type: 'string', description: 'Shortlisted | Interview | Maybe | Reject' },
score_breakdown: {
type: 'object',
properties: {
experience: { type: 'number' },
english: { type: 'number' },
reliability: { type: 'number' },
certifications: { type: 'number' },
availability: { type: 'number' },
personality: { type: 'number', description: 'Inferred personality traits (warmth, conscientiousness, adaptability) from profile data' },
culture_fit: { type: 'number', description: 'Alignment with employer culture and team dynamics' },
communication_style: { type: 'number', description: 'Clarity, tone, and professionalism of written communication' },
attendance_expectations: { type: 'number', description: 'Likelihood of meeting attendance and punctuality expectations' },
physical_requirements: { type: 'number', description: 'Ability to meet physical demands of the role (lifting, standing, stamina)' },
leadership_expectations: { type: 'number', description: 'Leadership potential and team guidance capability' },
job_related_answers: { type: 'number', description: 'How well the candidate demonstrates job-specific knowledge from their profile' },
verified_skills: { type: 'number', description: 'How well claimed skills match the job requirements' },
scenario_judgment: { type: 'number', description: 'Inferred problem-solving and decision-making ability' },
employer_requirements: { type: 'number', description: 'Match against employer-defined custom requirements' }
}
}
}
};
const JOB_DESC_SCHEMA = {
type: 'object',
properties: {
description: { type: 'string' },
responsibilities: { type: 'array', items: { type: 'string' } },
qualifications: { type: 'array', items: { type: 'string' } },
nice_to_haves: { type: 'array', items: { type: 'string' } }
}
};
const RESUME_SCHEMA = {
type: 'object',
properties: {
inferred_name: { type: 'string' },
years_experience: { type: 'number' },
skills: { type: 'array', items: { type: 'string' } },
certifications: { type: 'array', items: { type: 'string' } },
professional_summary: { type: 'string' },
cover_letter: { type: 'string' }
}
};
const INTERVIEW_EVAL_SCHEMA = {
type: 'object',
properties: {
overall_interview_score: { type: 'number' },
verdict: { type: 'string', description: 'hire | maybe | no' },
hire_recommendation: { type: 'string' },
integrity_score: { type: 'number' },
ai_flags: { type: 'array', items: { type: 'string' } },
category_scores: {
type: 'object',
properties: {
communication: { type: 'number' },
confidence: { type: 'number' },
experience_relevance: { type: 'number' },
culture_fit: { type: 'number' },
problem_solving: { type: 'number' },
personality: { type: 'number', description: 'Inferred personality traits from interview responses (warmth, conscientiousness, adaptability)' },
communication_style: { type: 'number', description: 'Clarity, tone, and professionalism of verbal communication' },
attendance_expectations: { type: 'number', description: 'Likelihood of meeting attendance and punctuality expectations based on interview signals' },
reliability: { type: 'number', description: 'Dependability and consistency signals from interview responses' },
physical_requirements: { type: 'number', description: 'Stated ability to meet physical demands of the role' },
leadership_expectations: { type: 'number', description: 'Leadership potential and team guidance capability' },
scenario_judgment: { type: 'number', description: 'How well the candidate handles real-world job scenarios' },
job_related_answers: { type: 'number', description: 'Accuracy and depth of job-specific answers' },
verified_skills: { type: 'number', description: 'Demonstrated proficiency in claimed skills' }
}
},
strengths: { type: 'array', items: { type: 'string' } },
concerns: { type: 'array', items: { type: 'string' } },
best_fit_roles: { type: 'array', items: { type: 'string' } },
summary: { type: 'string' },
reasoning: { type: 'string' }
}
};
/** 1. AI Job Description Generator */
export async function generateJobDescription(data) {
const prompt = `You are an expert hiring copywriter for the staffing/hospitality industry.
Generate a compelling, professional job posting for the following role.
Role Title: ${data.title}
Role Category: ${data.role_category}
Min Experience: ${data.min_experience_years} years
English Level Required: ${data.english_required}
Required Certifications: ${(data.certifications_required || []).join(', ') || 'None'}
Pay Range: $${data.pay_range_min}–$${data.pay_range_max}/hr
Location: ${data.location || 'Not specified'}
Custom Requirements: ${data.custom_requirements || 'None'}
Return a job description (2-3 engaging paragraphs), 5-7 responsibilities, 4-6 qualifications, and 3-4 nice-to-haves. Make it feel premium and human.`;
const res = await base44.integrations.Core.InvokeLLM({
prompt,
response_json_schema: JOB_DESC_SCHEMA,
model: 'claude_sonnet_4_6'
});
return res;
}
/** 2. AI Resume Builder — infers structured resume from free text */
export async function buildResumeFromText(freeText) {
const prompt = `You are an expert recruiter. A candidate described their experience in free text below.
Extract and infer a structured resume. Infer name, years of experience, relevant skills, certifications, and write a professional summary and a short cover letter.
Candidate's description:
"""
${freeText}
"""
Return a structured resume. If a field cannot be inferred, use an empty string or 0.`;
const res = await base44.integrations.Core.InvokeLLM({
prompt,
response_json_schema: RESUME_SCHEMA,
model: 'claude_sonnet_4_6'
});
return res;
}
/** 3. AI Candidate Screening — scores applicant vs job requirements */
export async function screenCandidate(application, job) {
const weights = job.vetting_criteria || { experience: 25, english: 20, reliability: 20, certifications: 20, availability: 15 };
const prompt = `You are KROW's AI screening engine. Evaluate this candidate against the job requirements.
Score each dimension 0-100, then compute a weighted overall score using these weights: experience ${weights.experience}%, english ${weights.english}%, reliability ${weights.reliability}%, certifications ${weights.certifications}%, availability ${weights.availability}%.
Additionally, score these supplementary dimensions 0-100: personality (inferred personality traits from profile data — warmth, conscientiousness, adaptability), culture_fit (alignment with employer culture and team dynamics), communication_style (clarity, tone, and professionalism of written communication), attendance_expectations (likelihood of meeting attendance and punctuality expectations), physical_requirements (ability to meet physical demands of the role — lifting, standing, stamina), leadership_expectations (leadership potential and team guidance capability), job_related_answers (how well the profile demonstrates job-specific knowledge), verified_skills (how well claimed skills match the job's required skills), scenario_judgment (inferred problem-solving ability from summary/cover letter), employer_requirements (match against employer-defined custom requirements).
JOB:
Title: ${job.title}
Category: ${job.role_category}
Min Experience: ${job.min_experience_years} years
English Required: ${job.english_required}
Required Certifications: ${(job.certifications_required || []).join(', ') || 'None'}
Physical Requirements: ${job.physical_requirements || 'Not specified'}
Leadership Expectations: ${job.leadership_expectations || 'Not specified'}
Attendance Expectations: ${job.attendance_expectations || 'Not specified'}
Custom Requirements: ${job.custom_requirements || 'None'}
CANDIDATE:
Name: ${application.applicant_name}
Years Experience: ${application.years_experience}
English Level: ${application.english_level}
Certifications: ${(application.certifications || []).join(', ') || 'None'}
Skills: ${(application.skills || []).join(', ') || 'None'}
Availability: ${(application.availability || []).join(', ') || 'Not specified'}
Professional Summary: ${application.professional_summary || 'Not provided'}
IMPORTANT: You are a RECOMMENDATION engine, not a decision-maker. Your recommendation guides the recruiter — the final hiring decision is always made by a human recruiter.
Be rigorous and realistic. Generate overall_score (0-100), match_label, summary, strengths, gaps, recommendation, and score_breakdown (0-100 each, including all supplementary dimensions: personality, culture_fit, communication_style, attendance_expectations, physical_requirements, leadership_expectations, job_related_answers, verified_skills, scenario_judgment, employer_requirements).`;
const res = await base44.integrations.Core.InvokeLLM({
prompt,
response_json_schema: SCREENING_SCHEMA,
model: 'claude_sonnet_4_6'
});
return res;
}
/** Generate the next interview question for "KROW" the AI interviewer */
export async function generateInterviewQuestion(history, jobTitle, questionNumber, language = 'en') {
const convo = history.map(m => `${m.role}: ${m.content}`).join('\n');
const langName = language === 'es' ? 'Spanish (español)' : 'English';
const prompt = `You are "KROW", a friendly but sharp AI interviewer for a ${jobTitle} position.
You are conducting a brief screening interview. Ask question ${questionNumber} of 5.
Adapt based on previous answers. Be conversational, warm, and specific. Ask ONE question at a time. Keep it concise (max 2 sentences).
You are NEVER an ATS. You do not keyword-match or run a résumé checklist. You behave as a psychologist (understanding the whole person), a recruiter (seeing fit), a coach (unlocking potential), a hiring manager (judging readiness), a teammate (curious, safe), and a mentor (invested in who they're becoming) — blended into one warm voice. Your purpose is to discover: who someone is, what they can do, how they think, how they learn, where they'll succeed, and who they'll become.
Cover these evaluation areas across the 5 questions: personality and work style, cultural fit and teamwork, required skills and job knowledge, communication style and professionalism, reliability and attendance expectations, physical readiness, and leadership potential. Distribute the questions so the interview probes a mix of these dimensions.
IMPORTANT: Conduct the ENTIRE interview in ${langName}. If the candidate switches languages, match theirs, but default to ${langName}.
Conversation so far:
${convo || '(just starting)'}
Ask your next question now in ${langName}. Respond with only the question text, no preamble.`;
const res = await base44.integrations.Core.InvokeLLM({
prompt,
model: 'gemini_3_flash'
});
return typeof res === 'string' ? res : res.text || String(res);
}
/** 5. AI Talent Matching — rank KROW talent pool against a job, so employers can hire without waiting for applications */
const TALENT_MATCH_SCHEMA = {
type: 'object',
properties: {
matches: {
type: 'array',
items: {
type: 'object',
properties: {
profile_id: { type: 'string', description: 'The id of the worker profile' },
match_score: { type: 'number', description: '0-100 fit score' },
match_label: { type: 'string', description: 'Strong Match | Good Match | Possible Fit | Weak Fit' },
reasons: { type: 'array', items: { type: 'string' }, description: '2-3 concise reasons this worker fits the job' },
recommendation: { type: 'string', description: 'Hire | Interview | Maybe | Pass' }
}
}
}
}
};
export async function matchTalentForJob(job, profiles) {
const candidates = (profiles || []).slice(0, 15).map((p) => ({
id: p.id,
name: p.full_name,
desired_position: p.desired_position || '',
current_position: p.current_position || '',
experience_years: p.experience_years || 0,
skills: p.skills || [],
languages: p.languages || [],
availability: p.availability || [],
certifications: p.certifications || [],
industries: p.industries || [],
strengths: p.strengths || [],
personality: p.personality || '',
communication_style: p.communication_style || '',
leadership_potential: p.leadership_potential || 0,
krow_score: p.krow_score || 0,
badges: (p.earned_badges || []).map((b) => b.name),
capabilities: (p.capabilities || []).map((c) => `${c.skill}${c.level ? ` (${c.level})` : ''}`),
shifts_completed: p.shifts_completed || 0,
attendance_score: p.attendance_score || 0,
}));
const prompt = `You are KROW's proactive talent-matching engine. An employer just posted a job. Match it against the available KROW talent pool — do not wait for applications, surface the best fits now.
JOB:
Title: ${job.title}
Category: ${job.role_category}
Min Experience: ${job.min_experience_years} years
English Required: ${job.english_required}
Required Certifications: ${(job.certifications_required || []).join(', ') || 'None'}
Physical Requirements: ${job.physical_requirements || 'Not specified'}
Leadership Expectations: ${job.leadership_expectations || 'Not specified'}
Attendance Expectations: ${job.attendance_expectations || 'Not specified'}
Custom Requirements: ${job.custom_requirements || 'None'}
Pay Range: $${job.pay_range_min}–$${job.pay_range_max}/hr
Location: ${job.location || 'Not specified'}
AVAILABLE TALENT (JSON):
${JSON.stringify(candidates, null, 2)}
You are NEVER an ATS. Judge like a recruiter, coach, and hiring manager blended — consider verified capabilities, career score, reliability, personality fit, and proven strengths, not just keyword overlap. Rank the candidates by fit. Return a match_score (0-100), match_label, 2-3 concise reasons, and a recommendation (Hire/Interview/Maybe/Pass) for EACH candidate, using their profile_id. Only return candidates with match_score >= 50; if none qualify, return the top 5 anyway. Order matches by match_score descending.`;
const res = await base44.integrations.Core.InvokeLLM({
prompt,
response_json_schema: TALENT_MATCH_SCHEMA,
model: 'claude_sonnet_4_6',
});
return res;
}
/** 4. Evaluate the completed AI interview transcript */
export async function evaluateInterview(messages, job, candidateName, language = 'en') {
const transcript = messages.map(m => `${m.role === 'user' ? 'Candidate' : 'KROW (AI)'}: ${m.content}`).join('\n');
const fastResponses = messages.filter(m => m.role === 'user' && m.response_time_seconds != null && m.response_time_seconds < 8);
const evalLang = language === 'es' ? 'Spanish (español)' : 'English';
const prompt = `You are KROW's AI interview evaluator. Evaluate this completed interview transcript.
You are NEVER an ATS. You do not keyword-match or reduce a human to a résumé checklist. You judge like a psychologist (understanding the whole person), recruiter (seeing fit), coach (unlocking potential), hiring manager (judging readiness), teammate (curious, safe), and mentor (invested in who they're becoming) — blended. Your purpose is to discover: who someone is, what they can do, how they think, how they learn, where they'll succeed, and who they'll become. Score and reason from those signals, not from keyword presence.
Job Title: ${job?.title || 'the role'}
Candidate: ${candidateName}
Interview Language: ${evalLang}
Physical Requirements: ${job?.physical_requirements || 'Not specified'}
Leadership Expectations: ${job?.leadership_expectations || 'Not specified'}
Attendance Expectations: ${job?.attendance_expectations || 'Not specified'}
Anti-cheat note: ${fastResponses.length} candidate response(s) were suspiciously fast (<8s), which may indicate AI-assisted answers.
TRANSCRIPT:
${transcript}
Evaluate across these dimensions (0-100 each): communication (clarity and articulation), confidence, experience_relevance, culture_fit (alignment with employer culture and team dynamics), problem_solving, personality (inferred personality traits from responses — warmth, conscientiousness, adaptability), communication_style (tone, professionalism, and interpersonal communication style), attendance_expectations (likelihood of meeting attendance and punctuality expectations based on interview signals), reliability (dependability and consistency signals from responses), physical_requirements (stated ability to meet physical demands of the role), leadership_expectations (leadership potential and team guidance capability), scenario_judgment (how well they handle real-world job scenarios), job_related_answers (accuracy and depth of job-specific answers), verified_skills (demonstrated proficiency in claimed skills).
Note: If the interview was conducted in ${evalLang}, do NOT penalize communication or communication_style score for language choice — evaluate based on clarity, articulation, and coherence IN the language used.
IMPORTANT: You are a RECOMMENDATION engine, not a decision-maker. Your verdict and hire_recommendation guide the recruiter — the final hiring decision is always made by a human recruiter.
Provide an overall_interview_score (0-100), verdict (hire/maybe/no), hire_recommendation, integrity_score (0-100, lower if cheating suspected), ai_flags, strengths, concerns, best_fit_roles, summary, and reasoning.
The category_scores object MUST include ALL dimensions: communication, confidence, experience_relevance, culture_fit, problem_solving, personality, communication_style, attendance_expectations, reliability, physical_requirements, leadership_expectations, scenario_judgment, job_related_answers, verified_skills.
Write the summary, reasoning, strengths, concerns, and best_fit_roles in ${evalLang} so the recruiter and candidate can understand them.`;
const res = await base44.integrations.Core.InvokeLLM({
prompt,
response_json_schema: INTERVIEW_EVAL_SCHEMA,
model: 'claude_sonnet_4_6'
});
return res;
}
/**
* Step 2 — Owliver conversation.
* Owliver is KROW's wise, warm owl companion who gets to know the worker
* through a 5-minute voice chat and learns 12 things about them.
*/
const OWLIVER_PROFILE_SCHEMA = {
type: 'object',
properties: {
career_goals: { type: 'string' },
desired_position: { type: 'string' },
current_position: { type: 'string' },
experience_years: { type: 'number' },
experience: {
type: 'array',
items: {
type: 'object',
properties: {
company: { type: 'string' },
role: { type: 'string' },
years: { type: 'number' }
}
}
},
skills: { type: 'array', items: { type: 'string' } },
languages: { type: 'array', items: { type: 'string' } },
availability: { type: 'array', items: { type: 'string' }, description: 'Use ONLY: Weekdays, Weekends, Evenings, Mornings, Overnight, On-Call' },
transportation: { type: 'string' },
certifications: { type: 'array', items: { type: 'string' } },
personality: { type: 'string', description: 'Inferred personality description' },
strengths: { type: 'array', items: { type: 'string' } },
weaknesses: { type: 'array', items: { type: 'string' } },
industries: { type: 'array', items: { type: 'string' } },
salary_expectations: { type: 'string' },
leadership_potential: { type: 'number', description: '0-100' },
communication_style: { type: 'string' },
summary: { type: 'string', description: '2-3 sentences Owliver would say about the worker' },
career_dna: {
type: 'object',
description: 'Career DNA — the living professional identity Owliver built from signals, not claims',
properties: {
identity: { type: 'string', description: 'One-line answer to "Who are you?" — who this person is at their core' },
learning_speed: { type: 'number', description: '0-100 — how quickly they pick things up, inferred from curiosity and self-directed learning signals' },
reliability: { type: 'number', description: '0-100 — can employers trust them, inferred from attendance/reliability signals' },
communication_score: { type: 'number', description: '0-100 — can they explain clearly, inferred from how they articulated answers' },
leadership_index: { type: 'number', description: '0-100 — do people follow them, inferred from ownership and initiative signals' },
adaptability_score: { type: 'number', description: '0-100 — can they learn and pivot, inferred from the simulation and reflection answers' },
work_style: { type: 'string', description: 'Independent | Team player | Builder | Operator | Leader — or a blend' },
culture_match: { type: 'array', items: { type: 'string' }, description: 'Environments and team cultures where this person will thrive' },
reputation_score: { type: 'number', description: '0-100 — baseline trust from verified signals in this conversation (starts modest; grows with verified work)' }
}
}
}
};
/** Owliver asks the next warm, conversational question covering the 12 areas. */
export async function owliverQuestion(history, workerName, questionNumber, language = 'en') {
const convo = history.map(m => `${m.role === 'user' ? 'Worker' : 'Owliver'}: ${m.content}`).join('\n');
const langName = language === 'es' ? 'Spanish (español)' : 'English';
const prompt = `You are "Owliver", a wise, warm, endlessly curious owl who works for the KROW Talent Engine. You are having a 5-minute voice chat with ${workerName}. This is NOT an interview — the person should never feel interviewed. You are a curious friend who discovers humans.
Your job is to build their Career DNA™ — a living professional identity — from a single conversation. While they simply talk, you silently read hundreds of signals: confidence, curiosity, reasoning, communication, emotional intelligence, problem-solving, ownership, learning mindset, reliability, leadership potential, cultural fit, career ambition. Never name these signals aloud. Never say "I'm scoring you." Just keep the human talking.
You are NEVER an ATS. You do not keyword-match, checklist-screen, or rank humans like résumés. You behave as a psychologist (understanding the whole person), a recruiter (seeing fit), a coach (unlocking potential), a hiring manager (judging readiness), a teammate (curious, safe), and a mentor (invested in who they're becoming) — all at once, blended into one warm voice.
Your purpose is to discover: who someone is, what they can do, how they think, how they learn, where they'll succeed, and who they'll become.
You combine seven philosophies at once (never mention any company):
• META — you are obsessed with understanding the human. Ask "Who are you?" not "Name, email, resume." Probe the hardest problem they've solved, what work gives them energy, what frustrates them, their perfect manager, how their friends describe them. You are mapping personality, communication style, leadership style, team compatibility, culture fit.
• CHATGPT — you don't interrogate. You chat. "Hey, imagine tomorrow is your first day at Google — what's the first thing you'd do?" Then "Interesting… why?" Then "Tell me more." They never realize they're being interviewed.
• CLAUDE — you chase nuance, never just score. When they say "I left because my manager wasn't supportive," you ask "What made you feel unsupported?" then later "What would a great manager have done?" Now you understand not just why they quit, but where they'll thrive.
• NVIDIA — every answer becomes a signal, not words. You note whether they answered fast, whether they got more confident, whether contradictions appear. The profile is mathematics, not adjectives.
• AMAZON — you discover operational data without asking. Instead of "Can you work weekends?" you ask "Tell me about your last Saturday." You uncover availability, reliability, and rhythm from real behavior.
• SPACEX — you run simulations, not questionnaires. You drop them into an impossible moment: "You're the only cook. 300 people arrive. The oven breaks. The chef won't answer. What do you do?" No multiple choice. Let them think. This reveals calmness, prioritization, execution, ownership.
• ELON — you never accept surface answers. "Walk me through the hardest thing you've ever built." "What was YOUR contribution?" "What failed?" "What would you do differently?" Keep digging until you hit first-principles thinking. Why? How? Show me.
• JENSEN — you measure curiosity, not experience. "What have you learned in the last 30 days that nobody asked you to learn?" "What are you trying to become?" "If nobody paid you, what would you still learn?"
Follow this 5-minute arc (you are on question ${questionNumber}):
1. INTRODUCTION — "Tell me about yourself in your own words." (Meta: who are you?)
2. STORY — "Tell me about a challenge you're proud of overcoming." (Elon: hardest thing built / your contribution / what failed.)
3. SIMULATION — Drop them into a realistic, high-pressure scenario tailored to whatever role or industry they've mentioned (cook, server, supervisor, etc.). If they haven't named one yet, use a fast-paced service moment. (SpaceX: let them think, no multiple choice.)
4. REFLECTION — "What feedback has made you better?" (Claude: what would a great manager have done?)
5. VISION — "What kind of work do you want to be known for in five years?" (Jensen: what are you trying to become / what would you still learn if nobody paid you?)
6. (Final) — a warm, human closing that invites anything they want to add. No more probing.
Rules for every message:
- ONE question. SHORT — one or two short sentences, under ~20 words for the question itself.
- Sound like a curious friend. Warm, casual, never clinical.
- Reference what they just said. If their answer is surface-level or vague, DON'T move on — dig with "Why?", "Tell me more.", "What was your part in that?", "What would you do differently?" Depth matters more than breadth.
- For the simulation (question 3), paint the scene vividly in one sentence, then ask what they'd do.
- Match the worker's language; default to ${langName}. Conduct the ENTIRE conversation in ${langName}.
Conversation so far:
${convo || '(just starting — begin with a warm hello and your first question: "Tell me about yourself in your own words.")'}
Ask your next message now in ${langName}. Respond with only what Owliver says, no preamble.`;
const res = await base44.integrations.Core.InvokeLLM({
prompt,
model: 'gemini_3_flash'
});
return typeof res === 'string' ? res : res.text || String(res);
}
/** After the chat, Owliver builds the structured worker profile from the transcript. */
export async function buildProfileFromConversation(messages, workerName, language = 'en') {
const transcript = messages.map(m => `${m.role === 'user' ? 'Worker' : 'Owliver'}: ${m.content}`).join('\n');
const langName = language === 'es' ? 'Spanish (español)' : 'English';
const prompt = `You are Owliver's Career DNA engine. From this conversation transcript between Owliver (the AI owl) and a worker named ${workerName}, build their Career DNA™ — a living professional identity made of signals, not claims.
The conversation followed a 5-minute arc: introduction (who are you), a proud challenge, a live job simulation, reflection on feedback, and a five-year vision. While they talked, Owliver read hundreds of signals: confidence, curiosity, reasoning, communication, emotional intelligence, problem-solving, ownership, learning mindset, reliability, leadership, cultural fit, ambition.
Owliver is NEVER an ATS. It does not keyword-match or checklist-screen a human. It blended six roles into one warm voice: psychologist (understanding the whole person), recruiter (seeing fit), coach (unlocking potential), hiring manager (judging readiness), teammate (curious, safe), mentor (invested in who they're becoming). Its purpose is to discover who someone is, what they can do, how they think, how they learn, where they'll succeed, and who they'll become. Build the Career DNA from that discovery — never from résumé-style matching.
First map the standard profile fields:
1. What they CAN DO → skills.
2. How WELL they perform → strengths, leadership_potential, communication_style, personality.
3. WHERE they have proven it → experience (array of {company, role, years}) and experience_years.
4. What CREDENTIALS they hold → certifications.
5. What ENVIRONMENTS they succeed in → industries, availability, transportation.
6. What skills they should BUILD NEXT → weaknesses (growth areas), career_goals.
7. Which OPPORTUNITIES match them now → desired_position, current_position, salary_expectations, languages.
Then build the career_dna object — the heart of the identity:
- identity: one honest line answering "Who are you?" — who this person is at their core, drawn from HOW they spoke, not just what they said.
- learning_speed (0-100): from curiosity and self-directed learning signals (Jensen lens — what did they learn nobody asked them to?).
- reliability (0-100): from attendance/punctuality/consistency signals (Amazon lens — their last Saturday, how they showed up). Be conservative if unverified.
- communication_score (0-100): clarity and articulation observed across the whole chat, not language choice.
- leadership_index (0-100): ownership and initiative in their story and simulation (Elon lens — what was YOUR contribution?).
- adaptability_score (0-100): from the simulation (calmness, prioritization) and reflection (SpaceX + Claude lenses).
- work_style: Independent | Team player | Builder | Operator | Leader, or a short blend.
- culture_match: a short list of environments/team cultures where they will thrive (inferred, not asked).
- reputation_score (0-100): a MODEST baseline — trust earned from this conversation alone. It is deliberately not 100; a real reputation grows only with verified shifts, projects, certifications, and employer reviews over time.
Transcript:
"""
${transcript}
"""
Rules:
- For availability, use ONLY these exact tokens when applicable: Weekdays, Weekends, Evenings, Mornings, Overnight, On-Call.
- For experience, infer an array of objects {company, role, years}.
- experience_years is a single best-estimate number across all roles.
- If an area was not covered, return an empty array or string, or 0 for numbers.
- Infer the career_dna scores from actual signals in the transcript, not guesses. If a signal is absent, score conservatively (low, not high).
- Write personality, communication_style, salary_expectations, summary, identity, work_style, and culture_match in ${langName}.
Return the structured Career DNA now.`;
const res = await base44.integrations.Core.InvokeLLM({
prompt,
response_json_schema: OWLIVER_PROFILE_SCHEMA,
model: 'claude_sonnet_4_6'
});
return res;
}

View File

@@ -1,708 +0,0 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { base44 } from '@/api/base44Client';
import { generateJobDescription, screenCandidate, matchTalentForJob } from './krowAi';
import { recalcProfilePatch } from './krowScore';
import { logActivity } from './userTracking';
import { evaluateChallenge } from './provingGround';
/**
* The signed-in user, on the `['user']` key the assistant and the 404 page
* already read. One cache entry means a profile edit reaches the navbar, the
* Profile page and Owliver's greeting without any of them knowing about each
* other.
*/
export function useCurrentUser() {
return useQuery({
queryKey: ['user'],
queryFn: () => base44.auth.me().catch(() => null),
staleTime: Infinity,
});
}
/** Saves profile fields and refreshes every consumer of `['user']`. */
export function useUpdateProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} patch */ (patch) => base44.auth.updateMe(patch),
onSuccess: (user) => {
queryClient.setQueryData(['user'], user);
queryClient.invalidateQueries({ queryKey: ['user'] });
},
});
}
/**
* Product preferences, stored on the user record.
*
* Reads fall back to the synchronous accessor so the first render already has
* the real values rather than a default that corrects itself a tick later.
*/
export function usePreferences() {
const { data: user } = useCurrentUser();
return { ...base44.auth.preferences(), ...(user?.preferences || {}) };
}
/**
* Writes one or more preferences and refreshes every consumer of `['user']`.
*
* The mutation resolves to `{ user, persisted, error }`, not to the user — a
* caller storing something it needs back after a reload, which is every caller
* writing `customSkills`, has to be able to tell a write that landed from one
* the browser refused. The cache is updated either way: the change is real for
* this session even when it could not be stored.
*/
export function useUpdatePreferences() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} patch */ (patch) => base44.auth.updatePreferences(patch),
onSuccess: /** @param {any} result */ (result) => {
queryClient.setQueryData(['user'], result.user);
queryClient.invalidateQueries({ queryKey: ['user'] });
},
});
}
export function useJobPostings() {
return useQuery({
queryKey: ['jobPostings'],
queryFn: () => base44.entities.JobPosting.list('-created_date', 100),
});
}
export function useJobPosting(id) {
return useQuery({
queryKey: ['jobPosting', id],
queryFn: () => base44.entities.JobPosting.get(id),
enabled: !!id,
});
}
export function useApplications(jobPostingId) {
return useQuery({
queryKey: ['applications', jobPostingId || 'all'],
queryFn: () =>
jobPostingId
? base44.entities.JobApplication.filter({ job_posting_id: jobPostingId }, '-ai_score', 200)
: base44.entities.JobApplication.list('-ai_score', 200),
});
}
export function useInterviews() {
return useQuery({
queryKey: ['interviews'],
queryFn: () => base44.entities.AIInterview.list('-created_date', 100),
});
}
/**
* Shifts worked, missed and overrun.
*
* A higher limit than the other collections because this is the one that grows
* per person per day: three workers over eight weeks is already north of a
* hundred records, and a truncated read would silently under-report attendance
* rather than fail.
*/
export function useShiftRecords() {
return useQuery({
queryKey: ['shiftRecords'],
queryFn: () => base44.entities.ShiftRecord.list('-created_date', 500),
});
}
export function useStaff() {
return useQuery({
queryKey: ['staff'],
queryFn: () => base44.entities.Staff.list('-created_date', 100),
});
}
export function useUpdateStaff() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.Staff.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['staff'] });
},
});
}
export function useRoleCategories() {
return useQuery({
queryKey: ['roleCategories'],
queryFn: () => base44.entities.RoleCategory.list('-created_date', 100),
});
}
export function useCertifications() {
return useQuery({
queryKey: ['certifications'],
queryFn: () => base44.entities.Certification.list('-created_date', 200),
});
}
export function useCreateCertification() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} data */ (data) => base44.entities.Certification.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['certifications'] });
},
});
}
export function useDeleteCertification() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} id */ (id) => base44.entities.Certification.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['certifications'] });
},
});
}
export function useCreateRoleCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} data */ (data) => base44.entities.RoleCategory.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roleCategories'] });
},
});
}
export function useUserActivity() {
return useQuery({
queryKey: ['userActivity'],
queryFn: () => base44.entities.UserActivity.list('-created_date', 500),
});
}
export function useCreateJobPosting() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} data */ (data) => base44.entities.JobPosting.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
logActivity('create_position');
},
});
}
export function useUpdateJobPosting() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.JobPosting.update(id, data),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
queryClient.invalidateQueries({ queryKey: ['jobPosting', variables.id] });
},
});
}
export function useCreateApplication() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} data */ (data) => base44.entities.JobApplication.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
logActivity('apply_job');
},
});
}
export function useUpdateApplication() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.JobApplication.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
},
});
}
export function useScreenCandidate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ async ({ application, job }) => {
const result = await screenCandidate(application, job);
return base44.entities.JobApplication.update(application.id, {
status: 'ai_screened',
ai_score: result.overall_score,
ai_match_label: result.match_label,
ai_summary: result.summary,
ai_strengths: result.strengths,
ai_gaps: result.gaps,
ai_recommendation: result.recommendation,
score_breakdown: result.score_breakdown,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
logActivity('screen_candidate');
},
});
}
export function useScreenAllCandidates() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ async ({ applications, job }) => {
const results = [];
for (const app of applications) {
if (app.status === 'applied' || app.status === 'ai_screened') {
const result = await screenCandidate(app, job);
const updated = await base44.entities.JobApplication.update(app.id, {
status: 'ai_screened',
ai_score: result.overall_score,
ai_match_label: result.match_label,
ai_summary: result.summary,
ai_strengths: result.strengths,
ai_gaps: result.gaps,
ai_recommendation: result.recommendation,
score_breakdown: result.score_breakdown,
});
results.push(updated);
}
}
return results;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
logActivity('screen_candidate');
},
});
}
export function useGenerateJobDescription() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ async ({ data, draftId }) => {
const result = await generateJobDescription(data);
if (draftId) {
await base44.entities.JobPosting.update(draftId, {
description: result.description,
responsibilities: result.responsibilities,
qualifications: result.qualifications,
nice_to_haves: result.nice_to_haves,
ai_generated: true,
});
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
queryClient.invalidateQueries({ queryKey: ['jobPosting', draftId] });
}
return result;
},
});
}
export function useHireCandidate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ async ({ application, job }) => {
const tier = application.ai_score >= 80 ? 'Skilled' : application.ai_score >= 60 ? 'Cross-Trained' : 'Beginner';
const updated = await base44.entities.JobApplication.update(application.id, { status: 'hired' });
await base44.entities.Staff.create({
name: application.applicant_name,
email: application.email,
phone: application.phone,
role: job?.title || job?.role_category || 'Staff',
profile_tier: tier,
hire_date: new Date().toISOString().split('T')[0],
application_id: application.id,
job_posting_id: application.job_posting_id,
ai_score: application.ai_score,
status: 'onboarding',
});
return updated;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
queryClient.invalidateQueries({ queryKey: ['staff'] });
logActivity('hire_candidate');
},
});
}
export function useMatchTalentForJob() {
return useMutation({
mutationFn: /** @param {any} vars */ async ({ job, profiles }) => matchTalentForJob(job, profiles),
});
}
export function useCreateInterview() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} data */ (data) => base44.entities.AIInterview.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interviews'] });
queryClient.invalidateQueries({ queryKey: ['applications'] });
logActivity('start_interview');
},
});
}
/* ===== University + Worker Intelligence Profile ===== */
export function useCourses() {
return useQuery({ queryKey: ['courses'], queryFn: () => base44.entities.Course.list('-created_date', 200) });
}
export function useCourse(id) {
return useQuery({ queryKey: ['course', id], queryFn: () => base44.entities.Course.get(id), enabled: !!id });
}
/**
* Author a skill in KROW Forge.
*
* A skill and the challenge that verifies it are one Course record — the entity
* already carries the skill (title, category, difficulty, proof_skill), the
* training (quiz, estimated_minutes), the proof (challenge.type) and the
* evaluation Owliver runs (challenge.rubric). So Forge authoring is a create on
* the entity the whole product already reads, not a parallel store.
*/
export function useCreateCourse() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} data */ (data) => base44.entities.Course.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['courses'] });
logActivity('create_skill_training');
},
});
}
/** Status changes and edits to an existing skill. */
export function useUpdateCourse() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.Course.update(id, data),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['courses'] });
queryClient.invalidateQueries({ queryKey: ['course', variables.id] });
},
});
}
/* ── Workforce assignment ──────────────────────────────────────────────── */
/** Every assignment on file. The workforce side of the source of truth. */
export function useAssignments() {
return useQuery({
queryKey: ['assignments'],
queryFn: () => base44.entities.Assignment.list('-created_date', 500),
});
}
/**
* Put people on a position.
*
* Deliberately a bulk mutation taking an already-decided list: the deciding
* happens in `lib/workforce.js` and the confirming happens in the UI, so by the
* time anything reaches here the admin has seen exactly who and how many. There
* is no code path that assigns somebody without that preview having been shown.
*
* Writes three things per person, because an assignment that updated only one
* of them would leave the system disagreeing with itself: the assignment
* record, the application's status where the person applied, and an activity
* event carrying every id involved.
*/
export function useAssignWorkers() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ async ({ position, workers = [], applications = [] }) => {
const startsAt = position.start_date
? new Date(position.start_date).toISOString()
: new Date().toISOString();
const endsAt = position.duration_months == null
? null
: new Date(new Date(startsAt).getTime() + position.duration_months * 30 * 86400000).toISOString();
const created = [];
for (const worker of workers) {
const record = await base44.entities.Assignment.create({
job_posting_id: position.id,
worker_email: worker.email,
worker_name: worker.name,
starts_at: startsAt,
ends_at: endsAt,
status: 'active',
source: 'owliver',
match_score: worker.score ?? null,
});
created.push(record);
/**
* The application is what puts a person *in the pipeline for this role*,
* and it is what every downstream step keys on — the candidate profile
* is addressed by it, and the AI interview takes one as its subject.
*
* So assigning somebody from the existing workforce creates one where
* none exists, exactly as the Position page's own "admit talent" path
* does. Without it an assigned worker would be unreachable: no record to
* open, and no way to interview them.
*/
let application = applications.find(
(a) => a.job_posting_id === position.id
&& String(a.email || '').toLowerCase() === String(worker.email || '').toLowerCase()
);
if (!application) {
application = await base44.entities.JobApplication.create({
applicant_name: worker.name,
email: worker.email || '',
phone: worker.profile?.phone || '',
years_experience: worker.profile?.experience_years || 0,
skills: worker.profile?.skills || [],
availability: worker.profile?.availability || [],
certifications: worker.profile?.certifications || [],
selfie_url: worker.profile?.selfie_url || '',
professional_summary: worker.profile?.career_goals || '',
cover_letter: '',
job_posting_id: position.id,
job_title: position.title,
status: 'assigned',
ai_score: worker.score ?? 0,
});
} else {
await base44.entities.JobApplication.update(application.id, { status: 'assigned' });
}
record.application_id = application.id;
await logActivity('assign_employee', {
details: `${worker.name} assigned to ${position.company ? `${position.company} — ` : ''}${position.title}`,
position_id: position.id,
application_id: application?.id || null,
worker_email: worker.email,
});
}
return created;
},
onSuccess: () => {
/* Everything that reads workforce state refreshes together, so the
position card, the detail page and Owliver's next answer cannot be
looking at three different moments. */
queryClient.invalidateQueries({ queryKey: ['assignments'] });
queryClient.invalidateQueries({ queryKey: ['applications'] });
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
queryClient.invalidateQueries({ queryKey: ['userActivity'] });
},
});
}
/**
* Put an assigned candidate in front of the AI interview.
*
* Deliberately *not* a call to `useCreateInterview`. `AIInterviewModal` — the AI
* Interview Owliver — writes its own `AIInterview` record when the interview
* actually finishes, carrying the transcript, the scores and the verdict.
* Creating one here would produce a second, empty interview record for the same
* person: a duplicate system, and a row claiming an interview happened when
* nobody has spoken yet.
*
* What is missing before an interview can run is the application being *at*
* interview stage. That is what this writes — the same status transition the
* Position and Candidates pages already perform — which is what makes the
* candidate appear as interview-ready everywhere and lets the existing modal
* open on them.
*/
export function useMarkInterviewReady() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ async ({ application, position }) => {
if (!application?.id) throw new Error('No application to schedule against');
const updated = await base44.entities.JobApplication.update(application.id, {
status: 'interview',
});
await logActivity('interview_ready', {
details: `${application.applicant_name} moved to interview for ${position?.title || application.job_title}`,
position_id: position?.id || application.job_posting_id,
application_id: application.id,
});
return updated;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
queryClient.invalidateQueries({ queryKey: ['interviews'] });
queryClient.invalidateQueries({ queryKey: ['userActivity'] });
},
});
}
export function useLearningPaths() {
return useQuery({ queryKey: ['learningPaths'], queryFn: () => base44.entities.LearningPath.list('-created_date', 100) });
}
export function useBadges() {
return useQuery({ queryKey: ['badges'], queryFn: () => base44.entities.Badge.list('-created_date', 200) });
}
/**
* The signed-in worker's profile, created on first access if absent.
*
* `enabled: false` skips it entirely — callers that only sometimes need the
* profile must gate it, because the query has a side effect (it creates the
* record) and should not run for employer or admin sessions.
*/
export function useWorkerProfile({ enabled = true } = {}) {
return useQuery({
enabled,
queryKey: ['workerProfile'],
queryFn: async () => {
const me = await base44.auth.me();
const list = await base44.entities.WorkerProfile.filter({ email: me.email }, '-created_date', 1);
if (list.length > 0) return list[0];
return base44.entities.WorkerProfile.create({
full_name: me.full_name || (me.email ? me.email.split('@')[0] : 'Worker'),
email: me.email,
experience_years: 0,
completed_courses: [],
earned_badges: [],
skills: [],
languages: [],
availability: [],
experience: [],
xp: 0,
krow_score: 0,
reliability_score: 0,
profile_completion: 10,
ai_interview_score: 0,
attendance_score: 100,
performance_score: 0,
client_rating: 0,
supervisor_rating: 0,
});
},
});
}
export function useWorkerProfiles() {
return useQuery({
queryKey: ['workerProfiles'],
queryFn: () => base44.entities.WorkerProfile.list('-krow_score', 500),
});
}
export function useUpdateWorkerProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.WorkerProfile.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['workerProfile'] });
queryClient.invalidateQueries({ queryKey: ['workerProfiles'] });
},
});
}
export function useCompleteCourse() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ async ({ profile, course, quizScore }) => {
const already = (profile.completed_courses || []).some(c => c.course_id === course.id);
const completed_courses = already ? profile.completed_courses : [
...profile.completed_courses,
{ course_id: course.id, title: course.title, score: quizScore, completed_date: new Date().toISOString() },
];
const level = course.difficulty === 'beginner' ? 'bronze' : course.difficulty === 'intermediate' ? 'silver' : 'gold';
const earned_badges = (already || !course.badge_reward) ? profile.earned_badges : [
...profile.earned_badges,
{ name: course.badge_reward, level, course_id: course.id, earned_date: new Date().toISOString() },
];
const xp = (profile.xp || 0) + (already ? 0 : (course.xp || 0));
const merged = { ...profile, completed_courses, earned_badges, xp };
const patch = { completed_courses, earned_badges, xp, ...recalcProfilePatch(merged) };
return base44.entities.WorkerProfile.update(profile.id, patch);
},
/* Completing training changes a verified skill level, and a level is read by
Forge, the profile and every position match — so the workforce list is
invalidated too, not just the one profile. */
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['workerProfile'] });
queryClient.invalidateQueries({ queryKey: ['workerProfiles'] });
},
});
}
/* ===== Proving Ground — evidence-based challenges ===== */
export function useEvidenceList(workerEmail) {
return useQuery({
queryKey: ['evidence', workerEmail || 'all'],
queryFn: () => workerEmail
? base44.entities.Evidence.filter({ worker_email: workerEmail }, '-created_date', 200)
: base44.entities.Evidence.list('-created_date', 200),
enabled: !!workerEmail,
});
}
export function useSubmitChallenge() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ async ({ profile, course, type, mediaUrl, transcript, identified }) => {
const result = await evaluateChallenge(course, { type, mediaUrl, transcript, identified, workerName: profile?.full_name });
const verdict = result.verdict || 'needs_work';
const score = Math.round(Number(result.score) || 0);
const passScore = Number(course.pass_score) || 70;
const passed = verdict === 'verified' && score >= passScore;
const evidence = await base44.entities.Evidence.create({
course_id: course.id,
course_title: course.title,
skill: course.proof_skill || course.title,
worker_email: profile.email,
worker_name: profile.full_name,
type,
media_url: mediaUrl || '',
transcript: identified ? JSON.stringify(identified) : (transcript || ''),
ai_verdict: verdict,
ai_score: score,
ai_rubric: result.rubric || {},
ai_feedback: result.feedback || '',
});
if (!passed) return { evidence, passed, result };
const already = (profile.completed_courses || []).some((c) => c.course_id === course.id);
const completed_courses = already ? profile.completed_courses : [
...profile.completed_courses,
{ course_id: course.id, title: course.title, score, completed_date: new Date().toISOString() },
];
const level = course.difficulty === 'beginner' ? 'bronze' : course.difficulty === 'intermediate' ? 'silver' : 'gold';
const earned_badges = (already || !course.badge_reward) ? profile.earned_badges : [
...profile.earned_badges,
{ name: course.badge_reward, level, course_id: course.id, earned_date: new Date().toISOString() },
];
const skill = course.proof_skill || course.title;
const capabilities = (profile.capabilities || []).some((c) => c.skill === skill)
? profile.capabilities
: [...(profile.capabilities || []), { skill, level: 'Verified', evidence_id: evidence.id, status: 'verified', verified_date: new Date().toISOString() }];
const xp = (profile.xp || 0) + (already ? 0 : (course.xp || 0));
const merged = { ...profile, completed_courses, earned_badges, capabilities, xp };
const patch = { completed_courses, earned_badges, capabilities, xp, ...recalcProfilePatch(merged) };
await base44.entities.WorkerProfile.update(profile.id, patch);
return { evidence, passed, result };
},
/* The whole loop lands here: evidence written, profile updated, and every
reader of a skill level — Forge, profiles, position matching — refreshed
from the same mutation that Owliver's verdict triggered. */
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['workerProfile'] });
queryClient.invalidateQueries({ queryKey: ['workerProfiles'] });
queryClient.invalidateQueries({ queryKey: ['evidence'] });
},
});
}
export function useVerifyEvidence() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ async ({ evidenceId }) =>
base44.entities.Evidence.update(evidenceId, {
supervisor_verified: true,
supervisor_name: 'Supervisor',
verified_date: new Date().toISOString(),
}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['evidence'] }),
});
}

View File

@@ -1,126 +0,0 @@
// Engine 6 — KROW Score Engine (formula-based, auto-recalculated)
// Reliability = Attendance×.30 + Performance×.25 + Education×.10 + ClientReviews×.15 + SupervisorReviews×.10 + Growth×.05 + Experience×.05
// KROW Score = Reliability×.40 + AI Interview×.25 + Education×.15 + Growth×.10 + Experience×.10
const clamp = (n) => Math.max(0, Math.min(100, n));
export function computeKrowScore(profile) {
const attendance = clamp(profile.attendance_score ?? 100);
const performance = clamp(profile.performance_score ?? 0);
const education = clamp(
(profile.completed_courses?.length || 0) * 12 +
(profile.earned_badges?.length || 0) * 8
);
const clientReviews = profile.client_rating ? clamp((profile.client_rating / 5) * 100) : 0;
const supervisorReviews = profile.supervisor_rating ? clamp((profile.supervisor_rating / 5) * 100) : 0;
const growth = clamp((profile.xp || 0) / 10);
const experience = clamp(
(profile.experience_years || 0) * 10 + (profile.experience?.length || 0) * 5
);
const reliability = Math.round(
attendance * 0.30 + performance * 0.25 + education * 0.10 +
clientReviews * 0.15 + supervisorReviews * 0.10 + growth * 0.05 + experience * 0.05
);
const krow_score = Math.round(
reliability * 0.40 +
clamp(profile.ai_interview_score || 0) * 0.25 +
education * 0.15 +
growth * 0.10 +
experience * 0.10
);
return {
krow_score,
reliability,
breakdown: { attendance, performance, education, clientReviews, supervisorReviews, growth, experience },
};
}
const CORE_FIELDS = ['full_name', 'email', 'phone', 'address', 'current_position', 'desired_position', 'career_goals', 'transportation', 'resume_url'];
export function computeProfileCompletion(profile) {
let filled = 0;
for (const f of CORE_FIELDS) if (profile[f]) filled++;
if (profile.languages?.length) filled++;
if (profile.availability?.length) filled++;
if (profile.skills?.length) filled++;
if (profile.experience?.length) filled++;
if (profile.completed_courses?.length) filled++;
if (profile.earned_badges?.length) filled++;
if (profile.ai_interview_score > 0) filled++;
const total = CORE_FIELDS.length + 5;
return Math.round((filled / total) * 100);
}
/** Returns only the recomputed fields — merge into an update patch */
export function recalcProfilePatch(profile) {
const { krow_score, reliability, breakdown } = computeKrowScore(profile);
return {
krow_score,
reliability_score: reliability,
profile_completion: computeProfileCompletion(profile),
score_breakdown: breakdown,
};
}
// Engine 11 (light) — recommendation helpers
export function recommendNextCourse(profile, learningPaths = [], courses = []) {
const p = profile || {};
const completedIds = new Set((p.completed_courses || []).map(c => c.course_id));
const desired = (p.desired_position || '').toLowerCase();
const path =
(desired && learningPaths.find(p =>
p.name?.toLowerCase().includes(desired) ||
p.target_role?.toLowerCase().includes(desired)
)) || learningPaths[0];
if (path?.steps?.length) {
const nextStep = path.steps.find(s => s.course_id && !completedIds.has(s.course_id));
const course = courses.find(c => c.id === nextStep?.course_id);
if (course) return { course, path };
}
const uncompleted = courses.filter(c => c.status === 'active' && !completedIds.has(c.id));
uncompleted.sort((a, b) => (b.xp || 0) - (a.xp || 0));
return uncompleted[0] ? { course: uncompleted[0], path: null } : { course: null, path };
}
export function computeJobMatch(profile, job) {
const p = profile || {};
let score = 0;
const desired = (p.desired_position || '').toLowerCase();
if (desired && (job.title?.toLowerCase().includes(desired) || job.role_category?.toLowerCase().includes(desired))) score += 35;
const profileSkills = new Set((p.skills || []).map(s => s.toLowerCase()));
const profileCerts = new Set((p.earned_badges || []).map(b => b.name?.toLowerCase()).filter(Boolean));
const reqSkills = (job.qualifications || []).map(q => q.toLowerCase());
const reqCerts = (job.certifications_required || []).map(c => c.toLowerCase());
let skillHits = 0;
for (const s of reqSkills) {
for (const ps of profileSkills) { if (s.includes(ps) || ps.includes(s)) { skillHits++; break; } }
}
if (reqSkills.length) score += Math.min(35, (skillHits / reqSkills.length) * 35);
let certHits = 0;
for (const c of reqCerts) {
for (const pc of profileCerts) { if (c.includes(pc) || pc.includes(c)) { certHits++; break; } }
}
if (reqCerts.length) score += Math.min(20, (certHits / reqCerts.length) * 20);
if ((p.experience_years || 0) >= (job.min_experience_years || 0)) score += 10;
return Math.min(100, Math.round(score));
}
export function recommendJobs(profile, jobPostings = [], limit = 3) {
return jobPostings
.filter(j => j.status === 'active')
.map(j => ({ job: j, match: computeJobMatch(profile, j) }))
.filter(r => r.match > 0)
.sort((a, b) => b.match - a.match)
.slice(0, limit);
}

35
src/lib/logger.js Normal file
View File

@@ -0,0 +1,35 @@
/**
* Console logger for the Doormile data layer.
*
* The API layer makes a lot of decisions that are invisible from the UI —
* which id field actually matched a booking, which miler a solver row resolved
* to, what shape an unverified endpoint really returned. Those need to be
* inspectable in development without shipping the noise to production, which
* is the whole job of this module.
*/
const LEVELS = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 };
const isDev = import.meta.env.DEV;
const MIN_LEVEL = isDev ? LEVELS.DEBUG : LEVELS.WARN;
const PREFIX = '%c[Doormile]';
const PREFIX_STYLE =
'background: #2563eb; color: #ffffff; padding: 2px 5px; border-radius: 4px; font-weight: bold;';
const print = (level, args) => {
if (LEVELS[level] < MIN_LEVEL) return;
const method = level === 'ERROR' ? console.error : level === 'WARN' ? console.warn : console.log;
const [message, ...rest] = args;
if (typeof message === 'string') method(`${PREFIX} ${message}`, PREFIX_STYLE, ...rest);
else method(PREFIX, PREFIX_STYLE, message, ...rest);
};
const logger = {
debug: (...args) => print('DEBUG', args),
info: (...args) => print('INFO', args),
warn: (...args) => print('WARN', args),
error: (...args) => print('ERROR', args),
};
export default logger;

View File

@@ -0,0 +1,64 @@
// ============================================================================
// Order status groups — which RAW `GET /admin/bookings` enums make up each
// operator-facing status the Orders page shows as a tab.
//
// Shared by the Orders page and anything else that counts orders by status,
// so a second definition cannot drift from the tabs an operator is looking
// at.
//
// ⚠ This is deliberately NOT the same taxonomy as queries.js's
// BOOKING_STATUS_TO_DELIVERY_STATUS, and the two must not be merged:
//
// • Orders page — tracks the OPERATOR's workflow. "Assigned"
// means the operator picked a rider, the moment assign-miler succeeds, so
// miler_assigned counts as Assigned.
// • Deliveries page (queries.js) — tracks the RIDER's engagement. Its "Accepted"
// means the rider accepted, a deliberately later and narrower bar, so it
// keeps miler_assigned on 'pending'.
//
// That split is explicit product direction — it was unified once and
// reverted. On Orders, "Assigned" is an operator action; on Deliveries,
// "Accepted" is a rider action, and the two screens must keep saying so.
// ============================================================================
export const ORDER_STATUS_GROUPS = {
pending: ['pending_pickup'],
assigned: ['converted_to_consignment', 'miler_assigned', 'pickup_scheduled'],
// The Orders page has no tab for this one — a booking that is out for
// delivery has left the operator's queue. It stays in the map so a status
// breakdown accounts for every row rather than silently dropping some.
active: ['out_for_delivery'],
delivered: ['delivered'],
cancelled: ['cancelled']
};
export const ORDER_STATUS_LABELS = {
pending: 'Pending',
assigned: 'Assigned',
active: 'Out for delivery',
delivered: 'Delivered',
cancelled: 'Cancelled'
};
// Display order — matches the lifecycle, and the order the Orders page's tabs
// appear in.
export const ORDER_STATUS_ORDER = ['pending', 'assigned', 'active', 'delivered', 'cancelled'];
const RAW_TO_GROUP = Object.entries(ORDER_STATUS_GROUPS).reduce((acc, [group, raws]) => {
raws.forEach((raw) => {
acc[raw] = group;
});
return acc;
}, {});
export const statusesInGroup = (group) => ORDER_STATUS_GROUPS[group] || [];
// Raw booking enum → group key. Returns the lowercased raw value itself for an
// enum this map has never seen, so an unmapped backend status stays visible in
// a breakdown rather than vanishing from the totals.
export const groupForBookingStatus = (raw) => {
const key = String(raw || '').toLowerCase();
return RAW_TO_GROUP[key] || key;
};
export const isInGroup = (raw, group) => statusesInGroup(group).includes(String(raw || '').toLowerCase());

View File

@@ -1,264 +0,0 @@
/**
* The position record, as one definition.
*
* Create Position (the form) and Owliver (the conversation) are two ways into
* the same record, and the field names, option lists and defaults have to be the
* same in both — a category the form offers and the conversation does not is a
* position that can only be created one way. So the shape lives here and both
* read it, rather than each carrying its own copy.
*
* Nothing in this file talks to the store. Creating a position is still
* `useCreateJobPosting`, in both paths.
*/
/** Certifications a position can require, in the order they are offered. */
export const CERT_OPTIONS = [
'Food Handler Card',
'ServSafe',
'ABC License',
'TIPS Certified',
'CPR/First Aid',
'Background Check Cleared',
];
/** English levels, stored as the value and shown as the label. */
export const ENGLISH_LEVELS = [
{ value: 'basic', label: 'Basic' },
{ value: 'conversational', label: 'Conversational' },
{ value: 'fluent', label: 'Fluent' },
{ value: 'native', label: 'Native' },
];
/** How each vetting weight reads when it is labelled. */
export const CRITERIA_LABELS = {
experience: 'Experience',
english: 'English',
reliability: 'Reliability',
certifications: 'Certifications',
availability: 'Availability',
};
/**
* A blank position.
*
* A function rather than a constant, so two callers cannot end up sharing — and
* mutating — the same `vetting_criteria` or certifications array.
*/
/** How long a position needs people for, in months. `null` is open-ended. */
export const DURATION_OPTIONS = [
{ value: '0.5', label: '2 weeks' },
{ value: '1', label: '1 month' },
{ value: '3', label: '3 months' },
{ value: '6', label: '6 months' },
{ value: '12', label: '1 year' },
{ value: '', label: '1+ year / ongoing' },
];
/** Urgency, as the employer states it. Drives allocation priority, not display. */
export const PRIORITY_OPTIONS = [
{ value: 'urgent', label: 'Urgent' },
{ value: 'high', label: 'High' },
{ value: 'normal', label: 'Normal' },
];
export function defaultPosition() {
return {
title: '',
role_category: 'Server',
/* ── Workforce demand ────────────────────────────────────────────────
What the employer is actually asking for. Everything the allocation
engine reasons about — who needs people first, whether one person can
cover a role for its whole run, whether they are free when it starts —
comes from these five fields, so they live on the position record rather
than being inferred from prose. */
company: '',
/* How many people. One is the common case and the old implicit one, so a
position created before this existed still means what it meant. */
headcount: 1,
/* ISO date, or '' for "as soon as possible" — which is not the same as
today, and the engine treats it as an immediate start. */
start_date: '',
/* Months. '' means open-ended, which is a real answer, not a missing one. */
duration_months: '',
priority: 'normal',
custom_requirements: '',
physical_requirements: '',
leadership_expectations: '',
attendance_expectations: '',
min_experience_years: 0,
english_required: 'basic',
location: '',
pay_range_min: '',
pay_range_max: '',
certifications_required: [],
/* What the work requires in verified skill levels — the field the matching
engine reads (lib/skillGraph.js). Empty means "no skill requirements
defined", and a position with none shows no match score rather than an
invented one. */
skill_requirements: [],
vetting_criteria: { experience: 25, english: 20, reliability: 20, certifications: 20, availability: 15 },
};
}
/**
* A draft — from either path — as the record the store is given.
*
* The two numeric fields are text inputs in the form, so they are coerced here
* rather than at each call site. Everything absent falls back to the same
* defaults the form starts from.
*/
export function toPositionPayload(draft = {}, { status = 'active' } = {}) {
const record = { ...defaultPosition(), ...draft };
return {
...record,
status,
pay_range_min: Number(record.pay_range_min) || 0,
pay_range_max: Number(record.pay_range_max) || 0,
min_experience_years: Number(record.min_experience_years) || 0,
/* At least one person is being asked for, whatever the field says. */
headcount: Math.max(1, Number(record.headcount) || 1),
/* Kept as a number or null rather than a string, because the allocation
engine compares it against assignment windows. Null is open-ended. */
duration_months: record.duration_months === '' || record.duration_months == null
? null
: Number(record.duration_months),
company: String(record.company || '').trim(),
};
}
/**
* "30 people · starts 4 Sep · 1 month" — the demand, in one line.
*
* Only the parts the record actually states. A position that declares no
* headcount, no start and no duration returns `null` and the caller renders
* nothing, rather than "1 person · starts immediately · ongoing" — which is
* three schema defaults wearing the appearance of an employer's answer.
*/
export function demandLabel(position = {}) {
const parts = [];
const count = Number(position.headcount);
if (Number.isFinite(count) && count > 0) {
parts.push(`${count} ${count === 1 ? 'person' : 'people'}`);
}
if (position.start_date) {
parts.push(`starts ${new Date(position.start_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}`);
} else if (position.start_date === '') {
/* An explicit empty start means "as soon as possible" — a stated answer.
An absent field means the question was never asked. */
parts.push('starts immediately');
}
const months = position.duration_months;
if (months != null) {
parts.push(
months < 1 ? `${Math.round(months * 4)} weeks`
: months === 1 ? '1 month'
: months < 12 ? `${months} months`
: months === 12 ? '1 year' : `${Math.round(months / 12)}+ years`
);
} else if ('duration_months' in position) {
parts.push('ongoing');
}
return parts.length ? parts.join(' · ') : null;
}
/** `$30–$40/hr`, or a single rate, or nothing when no pay is set. */
export function payLabel({ pay_range_min: min, pay_range_max: max } = {}) {
const low = Number(min) || 0;
const high = Number(max) || 0;
if (!low && !high) return null;
if (!high || high === low) return `$${low}/hr`;
return `$${low}–$${high}/hr`;
}
/** `fluent` → `Fluent`. Returns null for a level the record does not state. */
export function englishLabel(value) {
if (!value) return null;
return ENGLISH_LEVELS.find((l) => l.value === value)?.label
?? String(value).replace(/\b\w/, (c) => c.toUpperCase());
}
/**
* "0 years", "1 year", "3 years" — the number the employer actually entered.
*
* Zero is an answer, not an absence: a position open to people with no
* experience says so, and reading it back as "No minimum" would be this code
* rewording the employer rather than reporting them. `null` is returned only
* when the field carries nothing at all.
*/
export function experienceLabel(position = {}) {
const years = position.min_experience_years;
if (years === undefined || years === null || years === '') return null;
const n = Number(years);
if (!Number.isFinite(n)) return null;
return `${n} ${n === 1 ? 'year' : 'years'}`;
}
/** A single rate as its own value: `$24/hr`, or null when it was not set. */
export function rateLabel(value) {
if (value === undefined || value === null || value === '') return null;
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return null;
return `$${n}/hr`;
}
/**
* The prose expectations the employer wrote, in the order the form asks for them.
*
* `custom_requirements` is deliberately not in this list: it is a paragraph
* rather than a value, and it gets a block of its own — see
* `PositionCustomRequirements`.
*/
export const REQUIREMENT_FIELDS = [
{ key: 'physical_requirements', label: 'Physical Requirements' },
{ key: 'leadership_expectations', label: 'Leadership Expectations' },
{ key: 'attendance_expectations', label: 'Attendance Expectations' },
];
/**
* The requirements this position actually states.
*
* Only the ones with something in them. A requirement is role-specific by
* nature — a warehouse role states what it needs lifted, a server role does not
* — so a field left blank is not an omission to be reported, it is that role
* saying the question does not apply to it. Rendering an empty "Physical
* requirements" card on every position turns a blank answer into a demand, and
* makes every role look like the same template.
*
* The universal fields (title, category, location, pay, experience, English)
* behave the opposite way and are handled separately: those are questions every
* position answers, so an unanswered one is worth showing as unanswered.
*/
export function statedRequirements(position = {}) {
return REQUIREMENT_FIELDS
.map((field) => ({ ...field, value: String(position[field.key] ?? '').trim() }))
.filter((field) => field.value);
}
/** The custom requirement text, or `''` when none was written. */
export const customRequirementsText = (position = {}) =>
String(position.custom_requirements ?? '').trim();
/**
* Does this position state anything under Requirements at all?
*
* The gate a surface uses before drawing the heading — a section title standing
* over nothing is worse than no section.
*/
export const hasRequirements = (position = {}) =>
statedRequirements(position).length > 0 || (position.certifications_required || []).length > 0;
/**
* "Server · San Jose, CA · $24–$34/hr" — the terms of the role in one line.
*
* Company is deliberately absent: it belongs above the title, not beside the
* category. Parts the record does not carry are omitted rather than padded.
*/
export function roleMetaLine(position = {}) {
return [position.role_category, position.location, payLabel(position)]
.filter(Boolean)
.join(' · ');
}

65
src/lib/preferences.js Normal file
View File

@@ -0,0 +1,65 @@
import * as React from 'react';
/**
* Console preferences — the operator's own display settings.
*
* These belong to the browser, not to the Doormile account: the admin API has
* no preferences resource, and table density is a per-machine choice anyway
* (the same operator wants tighter rows on a warehouse monitor and looser ones
* on a laptop). Stored in localStorage, and changes broadcast on a custom event
* so every mounted consumer re-renders — `storage` alone only fires in *other*
* tabs, which would leave the table that triggered the change unchanged.
*/
const STORAGE_KEY = 'doormilePreferences';
const CHANGE_EVENT = 'doormile:preferences';
export const DEFAULT_PREFERENCES = {
/** Tighter row heights across every table. */
compactDensity: false,
/** Rows per page the tables open on. */
defaultPageSize: 25,
};
const read = () => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? { ...DEFAULT_PREFERENCES, ...JSON.parse(raw) } : { ...DEFAULT_PREFERENCES };
} catch {
return { ...DEFAULT_PREFERENCES };
}
};
/** Merges a patch into the stored preferences and notifies every consumer. */
export const writePreferences = (patch) => {
const next = { ...read(), ...patch };
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
/* A refused write (private mode, quota) still applies for this session —
the event below fires either way, so the UI stays consistent. */
}
window.dispatchEvent(new CustomEvent(CHANGE_EVENT, { detail: next }));
return next;
};
export function usePreferences() {
const [preferences, setPreferences] = React.useState(read);
React.useEffect(() => {
const sync = () => setPreferences(read());
window.addEventListener(CHANGE_EVENT, sync);
window.addEventListener('storage', sync);
return () => {
window.removeEventListener(CHANGE_EVENT, sync);
window.removeEventListener('storage', sync);
};
}, []);
return preferences;
}
/** `[preferences, update]` for screens that write as well as read. */
export function useUpdatePreferences() {
return React.useCallback((patch) => writePreferences(patch), []);
}

133
src/lib/profitability.js Normal file
View File

@@ -0,0 +1,133 @@
import { BATCHES, getBatchForHour } from '@/lib/batchBucket';
import { parseDoormileTimestamp } from '@/lib/doormileTimestamp';
/**
* The unit economics of a delivery day.
*
* Every figure the Profitability report shows — per rider and in total — comes
* from here, so the headline number and the table underneath it cannot
* disagree. In the source console these formulas existed in two places and had
* already started to drift.
*/
/** Revenue rule: ₹30 base for the first 8 km, then ₹6/km beyond it. */
export const BASE_REVENUE = 30;
export const BASE_KM_LIMIT = 8;
export const EXTRA_RATE_KM = 6;
/** Fixed salary cost, sliced per slot worked (₹500 across three slots). */
export const FIXED_COST_PER_SLOT = 500 / 3;
/** Variable fuel and wear cost per km. */
export const VARIABLE_RATE_KM = 2.5;
/**
* Orders in these states never delivered, so they earn no revenue and are not
* charged fuel or slot cost either. Counting them made every rider's margin
* read as though cancelled stops had been completed.
*/
const EXCLUDED_FROM_PROFIT = new Set(['cancelled', 'canceled', 'skipped']);
const orderRevenue = (order) => {
const km = parseFloat(order.riderkms || 0) || 0;
return km <= BASE_KM_LIMIT ? BASE_REVENUE : BASE_REVENUE + (km - BASE_KM_LIMIT) * EXTRA_RATE_KM;
};
/**
* The slot an order counts towards, and the day it belongs to.
*
* Both read `orderdate` — the booking's creation time. `assigntime` maps to the
* backend's last-modified column, so it is re-stamped by any status change and
* a rider's slot count drifts through the day; slot count feeds the fixed cost,
* so using it moved money. `parseDoormileTimestamp` rather than bare dayjs, for
* the same reason: the false trailing Z on some timestamps shifts a row by five
* and a half hours, and therefore into a different slot.
*/
const rowSlot = (order) => {
const raw = order?.orderdate;
if (!raw) return null;
const text = String(raw).trim();
/* A bare date has no time component and would always bucket to midnight. */
if (/^\d{4}-\d{2}-\d{2}$/.test(text)) return null;
const parsed = parseDoormileTimestamp(raw);
if (!parsed.isValid()) return null;
return getBatchForHour(parsed.hour() + parsed.minute() / 60);
};
export const rowDay = (order) => {
const raw = order?.orderdate;
if (!raw) return null;
const parsed = parseDoormileTimestamp(raw);
return parsed.isValid() ? parsed.format('YYYY-MM-DD') : null;
};
/**
* Revenue, cost and margin for one rider on one day.
*
* `orders` is the full day's set — the breakdown table deliberately still shows
* cancelled and skipped stops for context. Only the money excludes them.
*/
export function calcRiderMetrics(rider, selectedDate) {
const orders = (rider.orders ?? []).filter((order) => !selectedDate || rowDay(order) === selectedDate);
const billable = orders.filter(
(order) => !EXCLUDED_FROM_PROFIT.has(String(order.orderstatus ?? order.status ?? '').toLowerCase())
);
let revenue = 0;
let kms = 0;
const slotsByDate = {};
billable.forEach((order) => {
revenue += orderRevenue(order);
kms += parseFloat(order.riderkms || 0) || 0;
const slot = rowSlot(order);
const day = rowDay(order);
if (!slot || !day) return;
if (!slotsByDate[day]) slotsByDate[day] = new Set();
slotsByDate[day].add(slot);
});
/* Capped at three: a rider cannot be paid for more slots than exist in a day,
however many distinct windows their orders happen to fall across. */
const slotCount = Object.values(slotsByDate).reduce((sum, set) => sum + Math.min(set.size, BATCHES.length), 0);
const varCost = kms * VARIABLE_RATE_KM;
const fixedCost = slotCount * FIXED_COST_PER_SLOT;
const totalCost = varCost + fixedCost;
const net = revenue - totalCost;
return {
orders,
billable,
revenue,
kms,
slotCount,
varCost,
fixedCost,
totalCost,
net,
margin: revenue > 0 ? (net / revenue) * 100 : 0,
};
}
/**
* Groups delivery rows into riders.
*
* `riderkms` is what the revenue and cost math reads, and a booking carries no
* road distance — so the straight-line `kms` every other page already uses as
* the distance proxy is what feeds it here too.
*/
export function groupRowsByRider(rows) {
const byRider = new Map();
(rows || []).forEach((row) => {
if (!row.userid) return;
const key = String(row.userid);
if (!byRider.has(key)) {
byRider.set(key, { id: key, riderName: row.ridername || `Rider #${key}`, orders: [] });
}
byRider.get(key).orders.push({ ...row, riderkms: row.kms });
});
return [...byRider.values()];
}

View File

@@ -1,78 +0,0 @@
import { base44 } from '@/api/base44Client';
const CHALLENGE_EVAL_SCHEMA = {
type: 'object',
properties: {
verdict: { type: 'string', enum: ['verified', 'needs_work', 'failed'] },
score: { type: 'number', description: '0-100 weighted confidence score' },
rubric: { type: 'object', additionalProperties: { type: 'number' }, description: 'criterion -> 0-100' },
feedback: { type: 'string', description: '2-3 sentences, direct to the worker' },
strengths: { type: 'array', items: { type: 'string' } },
concerns: { type: 'array', items: { type: 'string' } }
}
};
/** Real-work unlock gate — shifts, reliability, and badges must be earned first. */
export function isUnlocked(course, profile = {}) {
const req = course?.unlock_requirements;
if (!req) return { unlocked: true, reasons: [] };
const reasons = [];
const shifts = Number(profile.shifts_completed) || 0;
const rel = Number(profile.reliability_score) || 0;
const badges = (profile.earned_badges || []).map((b) => b.name);
if (req.min_shifts && shifts < req.min_shifts) reasons.push(`${req.min_shifts} shifts completed (you have ${shifts})`);
if (req.min_reliability && rel < req.min_reliability) reasons.push(`Reliability ${req.min_reliability}+ (you have ${rel})`);
(req.required_badges || []).forEach((b) => { if (!badges.includes(b)) reasons.push(`Badge: ${b}`); });
return { unlocked: reasons.length === 0, reasons };
}
/** One sharp follow-up question during a roleplay challenge. */
export async function challengeFollowUp(course, history) {
const convo = history.map((m) => `${m.role === 'user' ? 'Worker' : 'KROW'}: ${m.content}`).join('\n');
const prompt = `You are "KROW", running a short proving-ground challenge for the skill "${course?.proof_skill || course?.title}".
Challenge scenario: ${course?.challenge?.prompt || course?.description}
You already posed the scenario. Now ask ONE sharp follow-up question that tests whether the worker can actually perform under pressure. Keep it under 25 words. Do not praise. Just ask.
Conversation so far:
${convo}
Ask your follow-up now. Respond with only the question.`;
const res = await base44.integrations.Core.InvokeLLM({ prompt, model: 'gemini_3_flash' });
return typeof res === 'string' ? res : res.text || String(res);
}
/** Evaluate a worker's proof — transcript for roleplay, attached media for photo/video,
* identified hazards for photo_identify (with the scene image attached). */
export async function evaluateChallenge(course, { type, mediaUrl, transcript, identified, workerName }) {
const ch = course?.challenge || {};
const skill = course?.proof_skill || course?.title;
const criteria = (ch.rubric || []).map((r) => r.criterion).join(', ') || 'overall_performance';
const rubricInstr = (ch.rubric || []).length
? `Score each criterion 0-100 in the "rubric" object: ${criteria}.`
: 'Score "overall_performance" 0-100 in the rubric object.';
let mediaPart;
const call = { response_json_schema: CHALLENGE_EVAL_SCHEMA, model: 'claude_sonnet_4_6' };
if (type === 'photo_identify') {
const list = (identified || []).map((h) => `- "${h.label}" at (${Math.round(h.x * 100)}%, ${Math.round(h.y * 100)}%)`).join('\n') || '(no hazards marked)';
mediaPart = `The worker was shown a kitchen photo and asked to identify cross-contamination and food-safety hazards. They marked these hazards:\n${list}\n\nAnalyze the attached kitchen photo and judge whether they identified the REAL risks (e.g. raw meat next to ready-to-eat food, same board for raw and cooked, soiled towels on food surfaces, food left in the temperature danger zone, unwashed hands). Reward correct, specific identifications; penalize misses and false positives.`;
if (mediaUrl) call.file_urls = [mediaUrl];
} else if (type === 'photo' || type === 'video') {
mediaPart = `The worker uploaded a ${type} demonstrating the challenge. Analyze the attached ${type} carefully and judge whether they actually performed the skill correctly.`;
if (mediaUrl) call.file_urls = [mediaUrl];
} else {
mediaPart = `Worker's responses (transcript):\n"""\n${transcript || '(no response)'}\n"""`;
}
call.prompt = `You are KROW's Proving Ground evaluator. A worker named ${workerName || 'the worker'} is proving the skill "${skill}" via a ${type} challenge.
Challenge: ${ch.prompt || course?.description || 'Demonstrate the skill.'}
${mediaPart}
${rubricInstr}
Compute a weighted score (0-100). verdict: "verified" if score>=70 and clearly competent, "needs_work" if 50-69, "failed" if <50.
Give feedback (2-3 sentences, direct and specific to the worker), strengths, and concerns.
Be rigorous — employers will trust this evidence. Do not inflate.`;
const res = await base44.integrations.Core.InvokeLLM(call);
return res;
}

View File

@@ -1,9 +0,0 @@
/**
* The role categories a position can be filed under.
*
* Shared because two things read them: the Create Position form renders them as
* options, and Owliver matches a spoken role against them. Custom categories
* added through the form come from the RoleCategory entity and are merged on
* top of these at both call sites.
*/
export const ROLE_CATEGORIES = ['Server', 'Bartender', 'Chef', 'Security', 'Picker', 'Event Staff', 'Other'];

View File

@@ -1,399 +0,0 @@
/**
* The skill graph: the one definition of how training becomes a verified level,
* and how a verified level becomes eligibility for a position.
*
* KROW has three things that used to know nothing about each other — Forge
* (training), worker profiles (what people can do) and positions (what the work
* needs). This module is the connection, and it is deliberately the only place
* the rules live:
*
* SKILL → LEVEL → TRAINING → COMPLETION → VERIFIED SKILL → MATCH
*
* Two decisions shape everything below.
*
* **Levels are derived, never stored.** A worker's level is computed from the
* training they have actually completed, so it cannot drift out of step with the
* evidence behind it, and completing a module updates the level everywhere at
* once without a single write. The exception is an explicit admin override,
* which is stored — because a manual verification is a claim by a person, and a
* claim has to be recorded somewhere to exist at all.
*
* **The match score is arithmetic, not judgement.** Every point is traceable to
* one requirement, one weight and one comparison of levels, so the reason for a
* score can always be shown next to it. Nothing here calls a model.
*
* Everything reads the existing entities — Course, WorkerProfile, JobPosting —
* through fields added to them, so swapping the demo store for a real API means
* changing nothing in this file.
*/
/* ── Levels ────────────────────────────────────────────────────────────── */
export const LEVELS = ['beginner', 'intermediate', 'advanced', 'expert'];
export const LEVEL_LABEL = {
beginner: 'Beginner',
intermediate: 'Intermediate',
advanced: 'Advanced',
expert: 'Expert',
};
/** Position in the ladder, or -1 for "no level held". */
export const levelIndex = (level) => LEVELS.indexOf(level);
export const levelLabel = (level) => LEVEL_LABEL[level] || 'Not started';
/**
* The skill catalogue.
*
* The ladders are different lengths on purpose. Food Safety and Customer Service
* top out at Advanced because there is no fourth thing to be good at — inventing
* an Expert tier for them would make "Expert" mean less everywhere else.
*/
export const SKILLS = [
{ id: 'server', name: 'Server', category: 'Service', levels: LEVELS },
{ id: 'bartending', name: 'Bartending', category: 'Bar', levels: LEVELS },
{ id: 'leadership', name: 'Leadership', category: 'Leadership', levels: LEVELS },
{ id: 'customer_service', name: 'Customer Service', category: 'Service', levels: LEVELS.slice(0, 3) },
{ id: 'food_safety', name: 'Food Safety', category: 'Kitchen', levels: LEVELS.slice(0, 3) },
];
export const skillById = (id) => SKILLS.find((s) => s.id === id) || null;
export const skillName = (id) => skillById(id)?.name || id;
/** The level above `level` in this skill's own ladder, or null at the top. */
export function nextLevelOf(skillId, level) {
const ladder = skillById(skillId)?.levels || LEVELS;
const at = level ? ladder.indexOf(level) : -1;
return ladder[at + 1] || null;
}
/* ── Training modules ──────────────────────────────────────────────────── */
/**
* A training module is a Course record carrying four extra fields:
*
* skill_id the skill it builds
* target_level the level it contributes to
* required_level the level a worker must already hold to start it
* completion_criteria what must be done
* verification_criteria what Owliver scores the evidence against
*
* Courses without `skill_id` are library material that no level depends on. They
* still appear in Forge; they just never move anyone's level.
*/
export const isModule = (course) => Boolean(course?.skill_id && course?.target_level);
export const modulesForSkill = (courses = [], skillId) =>
courses.filter((c) => c.skill_id === skillId);
/** Modules for one skill, grouped into its ladder — `{ beginner: [...], … }`. */
export function modulesByLevel(courses = [], skillId) {
const ladder = skillById(skillId)?.levels || LEVELS;
const grouped = Object.fromEntries(ladder.map((l) => [l, []]));
for (const course of modulesForSkill(courses, skillId)) {
if (grouped[course.target_level]) grouped[course.target_level].push(course);
}
return grouped;
}
/** What a module unlocks, as one line: "Server — Advanced". */
export const unlocksLabel = (course) =>
isModule(course) ? `${skillName(course.skill_id)} — ${levelLabel(course.target_level)}` : null;
/* ── What a worker has completed ───────────────────────────────────────── */
/**
* Completion is read off the profile the platform already writes.
*
* A course reaches `completed_courses` in exactly one way: the worker submitted
* evidence and Owliver passed it (`useSubmitChallenge`). So "completed" and
* "verified" are the same set here, and there is no second flag that could
* disagree with the evidence.
*/
export const completedCourseIds = (profile) =>
new Set((profile?.completed_courses || []).map((c) => c.course_id));
/* ── Level derivation ──────────────────────────────────────────────────── */
/**
* One skill, as this worker holds it.
*
* The progression rule, in one line: **a level is earned when every module at
* that level is verified and the level below it is earned.** That is what stops
* a worker who happens to pass one advanced module from being labelled Advanced
* while the ground under it is missing — the label has to mean the whole ladder.
*
* An admin override replaces the derived level and says so, so a manual
* verification is visible as a manual verification rather than passing itself
* off as earned training.
*/
export function skillStateFor(skillId, courses, profile) {
const skill = skillById(skillId);
const ladder = skill?.levels || LEVELS;
const done = completedCourseIds(profile);
const grouped = modulesByLevel(courses, skillId);
const levels = ladder.map((level) => {
const modules = grouped[level] || [];
const completed = modules.filter((m) => done.has(m.id));
return {
level,
label: levelLabel(level),
modules,
completed,
/* An empty level cannot be "complete" — a ladder rung with no training
behind it is an authoring gap, not an achievement. */
complete: modules.length > 0 && completed.length === modules.length,
progress: modules.length ? Math.round((completed.length / modules.length) * 100) : 0,
};
});
/* Earned levels stop at the first gap: the ladder is climbed, not sampled. */
let earnedIndex = -1;
for (let i = 0; i < levels.length; i += 1) {
if (!levels[i].complete) break;
earnedIndex = i;
levels[i].earned = true;
}
const override = (profile?.skill_overrides || {})[skillId] || null;
const overrideIndex = override ? ladder.indexOf(override.level) : -1;
const verifiedIndex = Math.max(earnedIndex, overrideIndex);
const verifiedLevel = verifiedIndex >= 0 ? ladder[verifiedIndex] : null;
const nextLevel = ladder[verifiedIndex + 1] || null;
const nextRung = nextLevel ? levels[verifiedIndex + 1] : null;
const nextRequirement = nextRung?.modules.find((m) => !done.has(m.id)) || null;
const totalCompleted = levels.reduce((n, l) => n + l.completed.length, 0);
const totalModules = levels.reduce((n, l) => n + l.modules.length, 0);
return {
skillId,
name: skill?.name || skillId,
category: skill?.category || '',
ladder,
levels,
verifiedLevel,
verifiedLabel: verifiedLevel ? levelLabel(verifiedLevel) : 'Not started',
verifiedByOverride: overrideIndex > earnedIndex ? override : null,
override,
nextLevel,
nextRequirement,
/* Progress towards the *next* level, which is the number a worker is
actually working against. Total-ladder progress is reported separately. */
progressToNext: nextRung ? nextRung.progress : 100,
completedInNext: nextRung?.completed.length || 0,
requiredForNext: nextRung?.modules.length || 0,
totalCompleted,
totalModules,
status: verifiedLevel
? (nextRung && nextRung.completed.length ? 'advancing' : 'verified')
: (nextRung && nextRung.completed.length ? 'in_progress' : 'not_started'),
/* "Next level ready" means the training is done and only the level label is
waiting — which never happens under the rule above, so it reads the rung
below: every module done except one. */
nearlyThere: Boolean(nextRung && nextRung.modules.length && nextRung.modules.length - nextRung.completed.length === 1),
};
}
/** Every skill in the catalogue, as this worker holds it. */
export const skillStates = (courses, profile) =>
SKILLS.map((s) => skillStateFor(s.id, courses, profile));
/** `{ server: 'advanced', food_safety: 'beginner' }` — the shape matching reads. */
export function verifiedLevels(courses, profile) {
const held = {};
for (const state of skillStates(courses, profile)) {
if (state.verifiedLevel) held[state.skillId] = state.verifiedLevel;
}
return held;
}
/**
* The profile as it would be with one more module completed.
*
* Used to answer "what would this training do for me" without writing anything.
* Same derivation runs over it, so the projected level and the real one can
* never be computed two different ways.
*/
export const withCourseCompleted = (profile, course) => ({
...profile,
completed_courses: [
...(profile?.completed_courses || []),
{ course_id: course.id, title: course.title, score: 100, completed_date: new Date().toISOString() },
],
});
/* ── Position requirements and matching ────────────────────────────────── */
/**
* Match bands. Four names, because a recruiter acts on a name, not a number —
* and a candidate should never have to interpret a percentage on their own.
*/
export const MATCH_BANDS = [
{ min: 90, label: 'Excellent Match', tone: 'success' },
{ min: 75, label: 'Strong Match', tone: 'brand' },
{ min: 50, label: 'Potential Match', tone: 'warning' },
{ min: 0, label: 'Not Ready', tone: 'risk' },
];
export const bandFor = (score) => MATCH_BANDS.find((b) => score >= b.min) || MATCH_BANDS[3];
/**
* Partial credit for being close.
*
* One level below the requirement earns half the weight: that candidate is a
* training module away from qualifying, and scoring them zero would hide exactly
* the people this system exists to find. Two levels below earns nothing —
* at that distance it is a different job.
*/
const creditFor = (gap) => (gap <= 0 ? 1 : gap === 1 ? 0.5 : 0);
/**
* Score one candidate against one position's skill requirements.
*
* Returns null when the position defines no skill requirements — a position that
* asks for nothing should show no score rather than an invented 100%.
*
* Weights are normalised, so requirements that do not add to 100 still produce a
* percentage that means what it says.
*/
export function matchPosition(position, held = {}) {
const requirements = position?.skill_requirements || [];
if (!requirements.length) return null;
const totalWeight = requirements.reduce((sum, r) => sum + (Number(r.weight) || 0), 0) || 1;
const lines = requirements.map((req) => {
const ladder = skillById(req.skill_id)?.levels || LEVELS;
const need = ladder.indexOf(req.level);
const have = held[req.skill_id] ? ladder.indexOf(held[req.skill_id]) : -1;
const gap = need - have;
const weight = (Number(req.weight) || 0) / totalWeight * 100;
const credit = creditFor(gap);
return {
skillId: req.skill_id,
name: skillName(req.skill_id),
required: req.level,
requiredLabel: levelLabel(req.level),
held: held[req.skill_id] || null,
heldLabel: held[req.skill_id] ? levelLabel(held[req.skill_id]) : 'Not started',
weight: Math.round(weight),
earned: Math.round(weight * credit),
met: gap <= 0,
close: gap === 1,
gap: Math.max(0, gap),
note: gap <= 0
? null
: gap === 1
? `One level below — ${levelLabel(req.level)} required`
: `Missing required ${skillName(req.skill_id)} level: ${levelLabel(req.level)}`,
};
});
const score = Math.round(lines.reduce((sum, l) => sum + (l.weight * creditFor(l.gap)), 0));
const band = bandFor(score);
return {
score,
band: band.label,
tone: band.tone,
lines,
met: lines.filter((l) => l.met),
gaps: lines.filter((l) => !l.met),
};
}
/**
* The training that closes the largest gap, and what completing it would do.
*
* This is the whole Forge↔Positions loop in one function: it names a module, the
* level that module unlocks, and the match score the candidate would hold after
* Owliver verifies it — computed by re-running the same derivation over a
* profile with that module added, so the promised number is the number they get.
*/
export function recommendTraining(position, profile, courses) {
const held = verifiedLevels(courses, profile);
const match = matchPosition(position, held);
if (!match || !match.gaps.length) return null;
/* Heaviest unmet requirement first — the one that moves the score most. */
const target = [...match.gaps].sort((a, b) => b.weight - a.weight)[0];
const state = skillStateFor(target.skillId, courses, profile);
/* The next module on the ladder, not the module at the required level: the
progression rule means levels are climbed in order, so the honest next step
is the first thing they have not done. */
const module = state.nextRequirement;
if (!module) return null;
const projectedHeld = verifiedLevels(courses, withCourseCompleted(profile, module));
const projected = matchPosition(position, projectedHeld);
return {
module,
skillId: target.skillId,
skillName: target.name,
fromLevel: state.verifiedLevel,
toLevel: module.target_level,
unlocks: unlocksLabel(module),
currentScore: match.score,
projectedScore: projected?.score ?? match.score,
requiredLabel: target.requiredLabel,
/* Whether this one module actually moves the match. It often does not —
a level takes every module on its rung, so someone three modules out gains
nothing measurable from the first. Callers that promise a number to a
recruiter should say nothing rather than promise "50% → 50%". */
improves: (projected?.score ?? match.score) > match.score,
};
}
/* ── Reading the workforce against a position ──────────────────────────── */
/** Links an application to the worker profile behind it, by email. */
export const profileForEmail = (profiles = [], email) =>
(email && profiles.find((p) => p.email?.toLowerCase() === String(email).toLowerCase())) || null;
/**
* Every worker scored against one position, best first.
*
* `applications` is optional: pass it and each row carries the application that
* connects that person to this role, which is what lets the admin Position page
* be one list — applicants and eligible workers — rather than two.
*/
export function rankWorkforce(position, profiles = [], courses = [], applications = []) {
const appliedBy = new Map(
applications
.filter((a) => a.job_posting_id === position?.id)
.map((a) => [String(a.email || '').toLowerCase(), a])
);
return profiles
.map((profile) => {
const held = verifiedLevels(courses, profile);
const match = matchPosition(position, held);
if (!match) return null;
return {
profile,
match,
held,
application: appliedBy.get(String(profile.email || '').toLowerCase()) || null,
recommendation: recommendTraining(position, profile, courses),
};
})
.filter(Boolean)
.sort((a, b) => b.match.score - a.match.score);
}
/** Positions this worker should be looking at, best match first. */
export function matchingPositions(profile, positions = [], courses = []) {
const held = verifiedLevels(courses, profile);
return positions
.map((position) => ({ position, match: matchPosition(position, held) }))
.filter((row) => row.match)
.sort((a, b) => b.match.score - a.match.score);
}

View File

@@ -1,506 +0,0 @@
import { CERT_OPTIONS, ENGLISH_LEVELS, toPositionPayload } from '@/lib/positionModel';
import { routeForPageKey } from './registry';
/**
* Skill actions.
*
* A skill file names the actions it may perform; this module is the only place
* that decides what those names do. Markdown is never executed — an unknown
* action name resolves to nothing rather than to arbitrary behaviour.
*
* Every action is a description of intent, returned to the panel to carry out
* with the app's own router. Nothing here writes data: the most an action does
* is open a form with fields filled in, which the user then reviews and submits
* through the existing flow.
*/
/* ── Natural-language extraction ────────────────────────────────────────── */
/**
* The role being asked for.
*
* Matched against the categories the app already has rather than a list written
* here, so "create a chef position" resolves to whatever the deployment calls
* that category — and a category added in Create Position is understood without
* a change to this file.
*/
export function extractRole(question, categories = []) {
const q = String(question).toLowerCase();
/* Longest match wins: "executive chef" should not resolve to "chef". */
let best = null;
for (const category of categories) {
const name = String(category).toLowerCase();
if (name && q.includes(name) && (!best || name.length > best.length)) best = category;
}
if (best) return best;
/* "…for a Sous Chef", "…a Line Cook position" — take the phrase the sentence
itself marks as the role when it matches no known category. */
const phrase = /(?:create|open|post|add|new|hiring|hire)\s+(?:a|an)?\s*([a-z][a-z\s/-]{2,40}?)\s*(?:position|role|job|opening)\b/i.exec(question)
|| /\b(?:position|role|job)\s+for\s+(?:a|an)?\s*([a-z][a-z\s/-]{2,40})/i.exec(question);
if (!phrase) return null;
const cleaned = phrase[1].trim().replace(/\s+/g, ' ');
return cleaned ? cleaned.replace(/\b\w/g, (c) => c.toUpperCase()) : null;
}
/** A location, when the sentence names one with "in" or "at". */
export function extractLocation(question) {
const match = /\b(?:in|at|based in|located in)\s+([A-Z][A-Za-z.'-]*(?:\s+[A-Z][A-Za-z.'-]*){0,3})/.exec(question);
if (!match) return null;
const value = match[1].trim();
/* "in Chennai" is a location; "in the morning" is not. */
return /^(The|A|An|This|That|Order|Total|Fact)$/i.test(value) ? null : value;
}
/**
* A pay range. Accepts "$30-$40/hr", "30 to 40 an hour", "$25/hr".
* Returns whole dollars, because the form's fields are numeric.
*/
export function extractPay(question) {
const range = /\$?\s*(\d{1,3})(?:\.\d+)?\s*(?:-|–|—|to)\s*\$?\s*(\d{1,3})(?:\.\d+)?/.exec(question);
if (range) {
const min = Number(range[1]);
const max = Number(range[2]);
if (min > 0 && max >= min) return { min, max };
}
const single = /\$\s*(\d{1,3})(?:\.\d+)?/.exec(question);
if (single) {
const value = Number(single[1]);
if (value > 0) return { min: value, max: value };
}
return null;
}
/**
* A minimum experience in years, when the sentence states one.
*
* "no experience needed" is an answer rather than a silence, so it returns 0 —
* which is what stops the conversation asking a question the request already
* settled.
*/
export function extractExperience(question) {
const q = String(question).toLowerCase();
if (/\b(no|zero)\s+(minimum|min|experience|exp)\b/.test(q) || /\bentry[- ]level\b/.test(q)) return 0;
const match = /(\d{1,2})\s*\+?\s*(?:years?|yrs?)(?:\s+(?:of\s+)?(?:experience|exp))?/.exec(q);
if (!match) return null;
const years = Number(match[1]);
return years >= 0 && years <= 40 ? years : null;
}
/** An English level, matched against the levels the record actually stores. */
export function extractEnglish(question) {
const q = String(question).toLowerCase();
const level = ENGLISH_LEVELS.find(({ value }) => new RegExp(`\\b${value}\\b`).test(q));
/* "english" alone names the field, not a level — leave it to be asked. */
return level ? level.value : null;
}
/**
* Certifications the sentence names, matched against the list the form offers.
*
* An explicit "no certifications" is an answer — an empty list — and returns
* `[]`, which is different from returning nothing.
*/
export function extractCertifications(question) {
const q = String(question).toLowerCase();
if (/\bno (?:certification|certificate|cert)s?\b|\bnone required\b/.test(q)) return [];
const found = CERT_OPTIONS.filter((cert) => q.includes(cert.toLowerCase()));
return found.length ? found : null;
}
/**
* Everything a position can be given from one sentence.
*
* Only fields the record actually has. Nothing is invented, and anything absent
* stays absent — for the conversation that is the difference between a question
* worth asking and one that repeats what the user already said.
*/
export function buildPositionPrefill(question, categories = []) {
const role = extractRole(question, categories);
const location = extractLocation(question);
const pay = extractPay(question);
const experience = extractExperience(question);
const english = extractEnglish(question);
const certifications = extractCertifications(question);
const prefill = {};
if (role) {
/* A known category fills the category field; anything else is a title, so a
one-off role is not silently filed under the wrong category. Either way
the title is set, because a position without one cannot be created. */
const known = categories.find((c) => String(c).toLowerCase() === String(role).toLowerCase());
if (known) prefill.role_category = known;
prefill.title = known || role;
}
if (location) prefill.location = location;
if (pay) {
prefill.pay_range_min = String(pay.min);
prefill.pay_range_max = String(pay.max);
}
if (experience !== null) prefill.min_experience_years = experience;
if (english) prefill.english_required = english;
if (certifications) prefill.certifications_required = certifications;
return { prefill, role, location, pay };
}
/**
* Words that are the sentence's own scaffolding rather than a skill.
*
* "Create a skill training" names nothing — matching it would put the word
* "Skill" in the name field, which the author then has to notice and delete.
* An empty field is the honest answer to a request that named no subject.
*/
const GENERIC_SKILL_WORDS = new Set([
'skill', 'skills', 'training', 'trainings', 'course', 'courses', 'challenge',
'challenges', 'new', 'another', 'one', 'it', 'this', 'that', 'them',
]);
/**
* The skill a Forge request is about.
*
* "Create a training for knife skills" → "Knife Skills". The sentence marks the
* subject with "for" or "about", or names it between the verb and the word
* training; anything else is left for the author to type, because a wrong guess
* in a form field is worse than an empty one.
*/
export function extractSkillName(question) {
const patterns = [
/\b(?:training|skill|challenge|course)s?\s+(?:for|on|about|covering)\s+(?:a|an|the)?\s*([a-z][a-z0-9\s&/'-]{2,50})/i,
/\b(?:create|add|build|make|new)\s+(?:a|an)?\s*([a-z][a-z0-9\s&/'-]{2,50}?)\s+(?:training|skill|challenge|course)\b/i,
];
for (const pattern of patterns) {
const match = pattern.exec(question);
if (!match) continue;
const cleaned = match[1]
.trim()
.replace(/\s+/g, ' ')
/* "for kitchen staff" names an audience, not a skill — drop the trailing
audience clause so the field holds the subject on its own. */
.replace(/\s+(?:for|to)\s+(?:my|our|the)?\s*\w+(?:\s+\w+)?$/i, '')
.trim();
if (cleaned.length < 3) continue;
/* A phrase made only of scaffolding named no subject. */
const words = cleaned.toLowerCase().split(/\s+/);
if (words.every((w) => GENERIC_SKILL_WORDS.has(w))) continue;
return cleaned.replace(/\b\w/g, (c) => c.toUpperCase());
}
return null;
}
/**
* The skill a "review" follow-up refers to.
*
* Owliver's draft is not stored anywhere — the follow-up chip carries the same
* subject the request did, and the draft is derived again from it. That is what
* lets a chip and a typed sentence go through one resolution path and arrive at
* the same draft, rather than a chip replaying a remembered answer.
*/
export function extractReviewSubject(question) {
const match = /\breview\s+(?:the\s+)?([a-z][a-z0-9\s&/'-]{2,60}?)\s+skill\b/i.exec(question);
if (!match) return null;
const cleaned = match[1].trim().replace(/\s+/g, ' ');
return cleaned.length >= 3 ? cleaned.replace(/\b\w/g, (c) => c.toUpperCase()) : null;
}
/* ── Draft suggestions ──────────────────────────────────────────────────── */
/**
* Starting points for a skill draft, by subject.
*
* These are suggestions an author reviews and edits, not knowledge the platform
* holds — which is why a subject matching nothing here yields no outline at all
* rather than three generic lines. An empty step the author fills in is honest;
* an invented curriculum is not.
*
* `proof` is a challenge type the challenge runner can actually run.
*/
const SKILL_TEMPLATES = [
{
terms: ['allerg', 'intoleran', 'cross-contact', 'cross contact'],
category: 'Safety & Hygiene',
difficulty: 'intermediate',
outline: ['Recognizing allergens', 'Preventing cross-contact', 'Handling allergy requests'],
proof: 'roleplay',
},
{
terms: ['food safety', 'hygiene', 'sanitation', 'sanitisation', 'haccp', 'temperature'],
category: 'Safety & Hygiene',
difficulty: 'intermediate',
outline: ['Safe holding temperatures', 'Storage and labelling', 'Cleaning and sanitising'],
proof: 'photo',
},
{
terms: ['hazard', 'ppe', 'manual handling', 'lifting', 'fire', 'first aid', 'accident'],
category: 'Safety & Hygiene',
difficulty: 'intermediate',
outline: ['Spotting the hazard', 'The safe method', 'What to do when it goes wrong'],
proof: 'photo_identify',
},
{
terms: ['knife', 'cut', 'chop', 'prep', 'plating'],
category: 'Kitchen',
difficulty: 'beginner',
outline: ['Grip and posture', 'The basic cuts', 'Working safely at speed'],
proof: 'video',
},
{
terms: ['guest', 'customer', 'service', 'complaint', 'front of house', 'hospitality'],
category: 'Guest Service',
difficulty: 'intermediate',
outline: ['Reading the guest', 'Handling a complaint', 'Recovering the experience'],
proof: 'roleplay',
},
{
terms: ['bar', 'cocktail', 'alcohol', 'licensing', 'pour'],
category: 'Bar',
difficulty: 'intermediate',
outline: ['Responsible service', 'Standard pours and specs', 'Refusing service'],
proof: 'roleplay',
},
{
terms: ['clean', 'housekeep', 'laundry', 'linen'],
category: 'Cleaning',
difficulty: 'beginner',
outline: ['The cleaning sequence', 'Chemicals and dilution', 'Checking your own work'],
proof: 'photo',
},
{
terms: ['till', 'cash', 'payment', 'pos', 'checkout'],
category: 'Operations',
difficulty: 'beginner',
outline: ['Opening and closing the till', 'Taking payment accurately', 'Handling discrepancies'],
proof: 'roleplay',
},
];
/** How each proof method reads when Owliver describes it in a sentence. */
export const VERIFICATION_LABEL = {
roleplay: 'Scenario-based assessment',
video: 'Recorded demonstration',
photo: 'Photo evidence',
photo_identify: 'Hazard identification',
};
const DIFFICULTIES = ['beginner', 'intermediate', 'advanced'];
/** The template whose terms the text matches, or null. Longest term wins. */
function matchTemplate(text) {
const q = String(text).toLowerCase();
let best = null;
for (const template of SKILL_TEMPLATES) {
for (const term of template.terms) {
if (q.includes(term) && (!best || term.length > best.term.length)) {
best = { ...template, term };
}
}
}
return best;
}
/**
* Everything the Add Skill Training flow can be given from one sentence.
*
* The name and any category the library already uses come from the request
* itself. A level, a starting outline and a proof method come from the template
* the subject matches, and are absent when it matches none — the flow exists to
* walk the author through those decisions, so a blank field is a prompt rather
* than a gap.
*/
export function buildSkillPrefill(question, categories = []) {
const title = extractSkillName(question) || extractReviewSubject(question);
const q = String(question).toLowerCase();
/* Longest match wins, so "front of house" beats "house". */
let category = null;
for (const candidate of categories) {
const name = String(candidate).toLowerCase();
if (name && q.includes(name) && (!category || name.length > category.length)) {
category = candidate;
}
}
const template = matchTemplate(`${title || ''} ${question}`);
/* A level the author names outranks the template's. */
const stated = DIFFICULTIES.find((d) => q.includes(d));
const difficulty = stated || template?.difficulty || null;
const outline = template?.outline || [];
const challengeType = template?.proof || null;
const prefill = {};
if (title) prefill.title = title;
if (category || template?.category) prefill.category = category || template.category;
if (difficulty) prefill.difficulty = difficulty;
if (outline.length) prefill.outline = [...outline];
if (challengeType) prefill.challengeType = challengeType;
return {
prefill,
title,
category: prefill.category || null,
difficulty,
outline,
challengeType,
verification: challengeType ? VERIFICATION_LABEL[challengeType] : null,
};
}
/**
* An existing skill, as the authoring flow's own fields.
*
* Adding training to a published skill edits that record rather than creating a
* second one, so the flow has to open holding everything the record already
* says. Suggested steps are added only when the skill carries no outline of its
* own — Owliver does not overwrite an author's work with a template.
*/
export function buildTrainingPrefill(course) {
if (!course) return { prefill: {}, suggested: [] };
const existing = (Array.isArray(course.training_outline) ? course.training_outline : []).filter(Boolean);
const template = existing.length
? null
: matchTemplate(`${course.title || ''} ${course.category || ''} ${course.description || ''}`);
const suggested = template?.outline || [];
const rubric = (course.challenge?.rubric || []).map((r) => r.criterion).filter(Boolean);
return {
prefill: {
courseId: course.id,
title: course.title || '',
category: course.category || '',
difficulty: course.difficulty || 'beginner',
description: course.description || '',
proof_skill: course.proof_skill || '',
outline: existing.length ? existing : suggested.length ? [...suggested] : [''],
estimated_minutes: String(course.estimated_minutes || 15),
challengeType: course.challenge?.type || 'roleplay',
prompt: course.challenge?.prompt || '',
criteria: rubric.length ? rubric : ['', '', ''],
pass_score: String(course.pass_score || 70),
badge_reward: course.badge_reward || '',
},
suggested,
};
}
/**
* The skill in the library a request names, or null.
*
* Matched against the records that exist rather than a list written here, so a
* skill added this morning is addressable this afternoon. Longest title wins,
* so "Knife Skills — Advanced" beats "Knife Skills".
*/
export function findCourseByName(question, courses = []) {
const q = String(question).toLowerCase();
let best = null;
for (const course of courses) {
const title = String(course.title || '').toLowerCase();
if (title.length >= 3 && q.includes(title) && (!best || title.length > best.title.length)) {
best = { course, title };
}
}
return best?.course || null;
}
/* ── Action implementations ─────────────────────────────────────────────── */
/**
* The handlers a skill may name. Each returns a plain description of what
* should happen; the panel performs it with the router it already has.
*/
const HANDLERS = {
/**
* Write the position the conversation collected.
*
* The one action in this module that results in a record, and it exists
* because the alternative — opening the manual form with fields filled in —
* made the user finish by hand a job they had already described in a sentence.
* The panel performs it with `useCreateJobPosting`, the same mutation the form
* calls, so there is one create path and the list refreshes the same way.
*
* Reached only from the confirmation step, after the position has been read
* back and the user has chosen to create it.
*/
/* `status` is the one the conversation's confirmation step chose — draft or
active — and is passed to the payload builder the form already uses, so
both routes write the same record with the same defaults. */
create_position: ({ draft, status }) => ({
type: 'create_position',
data: toPositionPayload(draft || {}, status ? { status } : undefined),
}),
/**
* Open the Add Skill Training flow, on the Forge page, with what the request
* already answered. Routing to the page carries the intent in navigation
* state — the panel does not reach into the page, and the page decides what to
* do with it.
*/
open_create_skill_training: ({ prefill }) => ({
type: 'navigate',
route: routeForPageKey('university'),
state: {
openSkillTraining: true,
...(prefill && Object.keys(prefill).length ? { prefill } : {}),
},
}),
/**
* The same flow, opened on its training step for a skill that already exists.
* `courseId` is what makes this an edit of that record rather than a second
* copy of it — the page reads it and saves back to the skill it came from.
*/
open_create_training: ({ prefill, courseId }) => ({
type: 'navigate',
route: routeForPageKey('university'),
state: {
openSkillTraining: true,
startStep: 'training',
...(courseId ? { courseId } : {}),
...(prefill && Object.keys(prefill).length ? { prefill } : {}),
},
}),
navigate_to_positions: () => ({ type: 'navigate', route: routeForPageKey('positions') }),
navigate_to_candidates: () => ({ type: 'navigate', route: routeForPageKey('candidates') }),
navigate_to_forge: () => ({ type: 'navigate', route: routeForPageKey('university') }),
navigate_to_analytics: () => ({ type: 'navigate', route: routeForPageKey('analytics') }),
/**
* Open whichever page a reading belongs to.
*
* The general form of the four fixed destinations above. `routeForPageKey`
* only knows addresses in the placement table, so an unrecognised page key
* resolves to nothing rather than to a guessed path — a skill cannot invent
* a destination by asking for one.
*/
open_related_page: ({ page }) => {
const route = routeForPageKey(String(page || ''));
return route ? { type: 'navigate', route } : null;
},
};
/**
* Resolves an action name to something to do.
*
* `skill.actions` gates it: a skill can only run what its own file declares,
* so a handler existing in code is not the same as a skill being allowed to use
* it. Unknown or undeclared names return null and nothing happens.
*/
export function runAction(name, { skill, ...payload } = {}) {
if (skill && !skill.actions.includes(name)) return null;
const handler = HANDLERS[name];
return handler ? handler(payload) : null;
}
export const ACTION_NAMES = Object.keys(HANDLERS);

View File

@@ -1,182 +0,0 @@
import { parseSkill } from './registry';
import { boardPatch, owliverPatch, patchFrontmatter } from './skillFields';
/**
* Account-authored skills, as stored.
*
* A custom skill is its Markdown source and nothing else — the same artefact a
* file in `src/skills/` is, read back by the same parser. These helpers exist so
* the Add Skill dialog and the Skills page write that list identically; two
* writers with two shapes would be a second skill system by accident.
*/
/**
* The starting definitions offered to an author.
*
* Two templates, because there are two jobs and one of them was being learned
* from the other's example. A UI skill's first draft declares a section; an
* Owliver skill's declares triggers and the shapes of an answer. Both are the
* same format, read by the same parser — what differs is which half of it the
* author is being handed.
*/
const frontMatter = ({ id, name, description, pages, fallback }) => [
`id: ${id || fallback.id}`,
`name: ${name || fallback.name}`,
`description: ${description || fallback.description}`,
'pages:',
(pages?.length ? pages : ['positions']).map((p) => ` - ${p}`).join('\n'),
'status: active',
].join('\n');
/**
* The identity block every definition opens with, and nothing else.
*
* The configuration that follows it — the `ui:` section, the `owliver:` block —
* is written by `boardPatch` and `owliverPatch`, which is the same writer the
* editors use on an existing file. A template that composed its own YAML was a
* second writer with a second field set, and the fields one knew about were not
* the fields the other did.
*/
const skeleton = (fields, fallback, body) => `---
${frontMatter({ ...fields, fallback })}
---
${body}
`;
/** A definition that draws a section on the pages it names. */
export const uiSkillTemplate = ({
id = '', name = '', description = '', pages = [],
/**
* `candidates.activity`, not `position.activity`.
*
* The default placement on the default page is `after-position-list-summary`
* — above the grid, with no position in context — so the old default composed
* a definition that drew a titled card and then reported that it needed a
* record the page never had. A template must produce something that works
* before it is edited, so the default reads a source that needs nothing.
*/
type = 'flow', placement = '', title = '', source = 'candidates.activity', periods = [],
} = {}) => {
const identity = { id, name, description, pages };
const fallback = {
id: 'my-ui-skill',
name: 'My UI Skill',
description: 'What this skill adds to the page.',
};
const heading = name || fallback.name;
return patchFrontmatter(
skeleton(identity, fallback, `# ${heading}
## Purpose
Describe what this section shows, and why it belongs on these pages.
## Capabilities
- Describe one thing the section reports.
- Add more as needed.`),
boardPatch({
...identity,
type,
placement,
/* A card with no title of its own is headed by the skill's name, which is
what the previous template wrote out. Kept, so a fresh draft reads the
same as it always did. */
title: title || heading,
source,
periods,
})
);
};
/** A definition that teaches Owliver what it can be asked for. */
export const owliverSkillTemplate = ({
id = '', name = '', description = '', pages = [],
triggers = [], suggestions = [], capabilities = [], responses = {},
} = {}) => {
const identity = { id, name, description, pages };
const fallback = {
id: 'my-owliver-skill',
name: 'My Owliver Skill',
description: 'What this skill helps Owliver answer.',
};
const label = name || fallback.name;
return patchFrontmatter(
skeleton(identity, fallback, `# ${label}
## Purpose
Describe what Owliver should be able to answer on these pages.
## Capabilities
- Describe one thing Owliver can be asked for.
- Add more as needed.`),
owliverPatch({
...identity,
/* A definition that claims no phrase of its own still answers to its
name — written out so the author can see what it will match on. */
triggers: triggers.length ? triggers : [label.toLowerCase()],
suggestions,
capabilities,
responses,
})
);
};
/**
* The template the Add Skill dialog offers.
*
* That dialog opens from Owliver's own header, mid-conversation, so what it
* hands the author is an Owliver skill. Kept under its original name because
* it is what the dialog already imports.
*/
export const skillTemplate = owliverSkillTemplate;
/** Parses a stored entry, tolerating the bare-string form. */
const sourceOf = (entry) => (typeof entry === 'string' ? entry : entry?.raw ?? '');
/**
* The stored list with `source` added or replaced.
*
* Matching is by skill id, so editing a skill overwrites its own entry rather
* than adding a near-duplicate beside it.
*/
export function upsertCustomSkill(existing = [], source) {
const skill = parseSkill(source, { custom: true });
const rest = existing.filter((entry) => {
try {
return parseSkill(sourceOf(entry), { custom: true }).id !== skill.id;
} catch {
return true;
}
});
return { skill, next: [...rest, { path: `custom/${skill.id}.md`, raw: source }] };
}
/** The stored list without the skill of this id. */
export function removeCustomSkill(existing = [], id) {
return existing.filter((entry) => {
try {
return parseSkill(sourceOf(entry), { custom: true }).id !== id;
} catch {
return true;
}
});
}
/** The stored Markdown for one custom skill, or null if the account has none. */
export function customSkillSource(existing = [], id) {
for (const entry of existing) {
try {
if (parseSkill(sourceOf(entry), { custom: true }).id === id) return sourceOf(entry);
} catch {
/* An unparseable entry cannot be the one being edited. */
}
}
return null;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,361 +0,0 @@
import { doc, heading, insights, list, note, text } from '@/components/ai-assistant/blocks';
import { CRITERIA_LABELS, ENGLISH_LEVELS, payLabel } from '@/lib/positionModel';
import { levelLabel, skillName } from '@/lib/skillGraph';
/**
* Continuing a saved draft, in the conversation that saved it.
*
* A draft is a `JobPosting` with `status: 'draft'` — the same record the form
* writes and the Positions list shows. Finishing one used to mean leaving the
* panel for the Create Position page, which threw away the conversation that
* produced it and made the assistant a launcher for a form rather than a place
* to work.
*
* So the remaining steps happen here: read the draft back, generate its
* description, read and set its vetting weights, publish it. Every one of them
* acts on the position record through the mutations the form already uses —
* this module holds no draft of its own, no copy of the weights, and no second
* description. It decides what was asked and what to say; the panel performs
* the write.
*/
/* ── Which question is this? ───────────────────────────────────────────── */
const has = (q, ...terms) => terms.some((t) => q.includes(t));
/**
* The draft intent behind a question, or null to let the page answer it.
*
* Ordered by specificity: setting weights before showing them, publishing
* before continuing, so "publish the bartender draft" is not read as a request
* to reopen it.
*/
export function matchDraftIntent(question) {
const q = String(question).toLowerCase();
if (has(q, 'generate job description', 'generate the job description', 'generate a job description',
'write the job description', 'generate description')) return 'generate_description';
if (has(q, 'set ai vetting', 'set the vetting', 'set vetting', 'change the vetting', 'change vetting',
'adjust the vetting', 'adjust vetting', 'update the vetting', 'update vetting')) return 'set_weights';
if (has(q, 'explain the vetting', 'explain vetting', 'why these weights', 'what do the vetting weights mean')) {
return 'explain_weights';
}
if (has(q, 'vetting weight', 'vetting weights', 'ai vetting')) return 'show_weights';
if (has(q, 'publish this position', 'publish the position', 'publish this draft', 'publish the draft',
'publish it')) return 'publish';
if (has(q, 'continue the', 'continue this draft', 'continue draft', 'finish the draft',
'finish this draft', 'open the draft')) return 'continue';
/**
* Asking about drafts in general.
*
* Claimed here rather than left to fall through, and that is the fix for the
* navigation: an unclaimed question containing the word "position" is routed
* by keyword, and Create Position lists `position` and `role` among its
* topics — so "which positions are still in draft?" was answered by *opening
* the form*. It is a question about records that exist; it is answered where
* it was asked. Older chips carrying this wording are caught by the same
* match, so a thread written before this still behaves.
*/
if (has(q, 'still in draft', 'in draft', 'drafts to finish', 'unfinished draft',
'which drafts', 'my drafts', 'any drafts')) return 'list';
return null;
}
/**
* The draft a question is about.
*
* A named record first — a Continue control on a card, or a chip this flow
* wrote, knows exactly which position it means and says so — then the title in
* the sentence, then the position the page has open, then the only draft there
* is. Nothing is assumed when more than one draft could be meant: the caller
* asks instead, because acting on the wrong record is not recoverable.
*/
export function resolveDraft(question, positions = [], currentId = null, pinnedId = null) {
const pinned = pinnedId ? positions.find((p) => p.id === pinnedId) : null;
if (pinned) return { position: pinned };
const q = String(question).toLowerCase();
const drafts = positions.filter((p) => p.status === 'draft');
const named = drafts
.filter((p) => p.title && q.includes(String(p.title).toLowerCase()))
.sort((a, b) => String(b.title).length - String(a.title).length)[0];
if (named) return { position: named };
/* The record the page is already working on, draft or not: "generate the job
description" on an open position means that position. */
const current = currentId ? positions.find((p) => p.id === currentId) : null;
if (current) return { position: current };
if (drafts.length === 1) return { position: drafts[0] };
if (drafts.length > 1) return { ambiguous: drafts };
return {};
}
/* ── Reading the record back ───────────────────────────────────────────── */
const englishLabelOf = (value) =>
ENGLISH_LEVELS.find((l) => l.value === value)?.label || null;
/** `Bartending (Advanced), Customer Service (Intermediate)`, or null. */
const skillsLabelOf = (position) => (
position.skill_requirements?.length
? position.skill_requirements
.map((s) => `${skillName(s.skill_id)} (${levelLabel(s.level)})`)
.join(', ')
: null
);
/** Everything the draft states, in the order the form asks for it. */
function draftLines(position) {
const custom = String(position.custom_requirements || '').trim();
return [
position.company ? `Company: ${position.company}` : null,
position.title ? `Role: ${position.title}` : null,
position.role_category && position.role_category !== position.title
? `Category: ${position.role_category}` : null,
position.location ? `Location: ${position.location}` : null,
payLabel(position) ? `Pay: ${payLabel(position)}` : null,
position.min_experience_years
? `Experience: ${position.min_experience_years}+ years`
: 'Experience: no minimum',
englishLabelOf(position.english_required) ? `English: ${englishLabelOf(position.english_required)}` : null,
position.certifications_required?.length
? `Certifications: ${position.certifications_required.join(', ')}`
: null,
/* The two fields the form asks for that the earlier read-back skipped. Both
are things the author typed and would otherwise have to reopen the form to
check — which is the one thing continuing here is meant to avoid. */
skillsLabelOf(position) ? `Required skills: ${skillsLabelOf(position)}` : null,
custom ? `Custom requirements: ${custom}` : null,
].filter(Boolean);
}
/**
* The actions a draft actually invites, in the order they are usually taken.
*
* Only what is valid for this record: a draft that already has a description is
* not offered generation again as though nothing had happened, and "continue"
* is never offered by the reply that just continued it.
*/
export function draftActions(position) {
const isDraft = position.status === 'draft';
const { entries, total } = weightsOf(position);
/**
* Every chip names the record it acts on.
*
* A prompt alone resolves by title, which is right for something typed but
* wrong for a control that was built from a specific position: two drafts
* called Bartender would make the panel ask which one, having just been told.
* `positionId` is the answer it already has, carried forward.
*/
const on = (label, prompt) => ({ label, prompt, positionId: position.id });
return [
/* The form's own wording, so the same act is called the same thing in both
places. The two weight actions stay separate: reading the split and
understanding it are different questions. */
on(
position.description ? 'Regenerate Job Description with AI' : 'Generate Job Description with AI',
`Generate job description for ${position.title}`
),
on(
entries.length ? `AI Vetting Weights — Total ${total}%` : 'Show AI Vetting Weights',
`Show AI vetting weights for ${position.title}`
),
on('Explain Vetting Weights', `Explain vetting weights for ${position.title}`),
isDraft
? on('Publish this position', `Publish this position: ${position.title}`)
: { label: 'Match candidates', prompt: `Who matches ${position.title}?` },
];
}
/**
* The draft, read back where it was saved.
*
* Everything the record states, including the two things the reader would
* otherwise reopen the form for: the weights as they actually stand, and the
* description if one has been written.
*/
export const draftContinuedReply = (position) => {
const { entries, total } = weightsOf(position);
return doc(
heading(`Continuing the ${position.title} draft`, position.status === 'draft' ? 'Saved, not published' : 'Published'),
list(draftLines(position)),
entries.length
? text(`**AI vetting weights** — ${entries.map(([key, value]) => `${CRITERIA_LABELS[key] || key} ${value}%`).join(' · ')}. Total ${total}%.`)
: null,
position.description ? heading('Job description', 'Already written') : null,
position.description ? text(position.description) : null,
position.description
? note('Regenerating replaces it.')
: note('Nothing is written until you choose one of these.')
);
};
/** Ambiguity, stated rather than guessed at. */
export const whichDraftReply = (drafts) => ({
doc: doc(
text('Which draft should I continue?'),
list(drafts.map((p) => [p.title, p.company, p.location].filter(Boolean).join(' · ')))
),
followUp: drafts.slice(0, 4).map((p) => ({
label: p.title,
prompt: `Continue the ${p.title} draft`,
/* Named by id, so two drafts sharing a title are still two answers. */
positionId: p.id,
})),
});
/** Nothing to continue. */
export const noDraftReply = () => doc(
text('There is no draft to continue — every position here is published or closed.'),
note('Ask me to create a position and I will collect it here.')
);
/* ── Vetting weights ───────────────────────────────────────────────────── */
/** The weights this position actually carries, never a default dressed as one. */
export function weightsOf(position) {
const criteria = position?.vetting_criteria || {};
const entries = Object.entries(criteria).filter(([, value]) => Number.isFinite(Number(value)));
return {
entries,
total: entries.reduce((sum, [, value]) => sum + Number(value), 0),
};
}
export function weightsReply(position) {
const { entries, total } = weightsOf(position);
if (!entries.length) {
return doc(
heading('AI vetting weights', position.title),
text('This position carries no vetting weights, so screening scores every dimension evenly.')
);
}
return doc(
heading('AI vetting weights', position.title),
insights(entries.map(([key, value]) => ({
tone: 'neutral',
title: `${CRITERIA_LABELS[key] || key} — ${value}%`,
body: null,
}))),
note(total === 100
? 'Totals 100%. Screening weighs each dimension by these shares.'
: `These total ${total}%. Screening expects 100% — tell me the new split and I will set it.`)
);
}
export const explainWeightsReply = (position) => {
const { entries } = weightsOf(position);
const leading = [...entries].sort((a, b) => b[1] - a[1])[0];
return doc(
heading('What the vetting weights do', position.title),
text('Screening scores every applicant on each dimension, then weights those scores by these shares to produce the KROW score you see on a candidate card.'),
leading
? text(`On this position **${CRITERIA_LABELS[leading[0]] || leading[0]}** carries the most weight at **${leading[1]}%**, so a candidate strong there scores higher than one strong elsewhere.`)
: text('This position carries no weights, so no dimension counts more than another.'),
note('Tell me a new split — "set experience to 40 and english to 20" — and I will write it to this position.')
);
};
/**
* A new split, read from a sentence.
*
* Only the dimensions the position already defines can be set, and only to
* numbers the sentence actually states. Returns `null` when nothing was named,
* so a vague request is asked about rather than guessed at.
*/
export function weightsFromQuestion(question, position) {
const q = String(question).toLowerCase();
const current = position?.vetting_criteria || {};
const patch = {};
for (const key of Object.keys(current)) {
const label = (CRITERIA_LABELS[key] || key).toLowerCase();
const pattern = new RegExp(`${label}[^0-9]{0,12}(\\d{1,3})`);
const found = pattern.exec(q);
if (found) {
const value = Math.max(0, Math.min(100, Number(found[1])));
patch[key] = value;
}
}
return Object.keys(patch).length ? { ...current, ...patch } : null;
}
export const weightsSetReply = (position, next) => {
const total = Object.values(next).reduce((sum, value) => sum + Number(value), 0);
return doc(
heading('Vetting weights updated', position.title),
insights(Object.entries(next).map(([key, value]) => ({
tone: 'neutral',
title: `${CRITERIA_LABELS[key] || key} — ${value}%`,
}))),
note(total === 100
? 'Saved to this position. Screening from now on uses this split.'
: `Saved, but these total ${total}%. Screening expects 100% — say the rest and I will adjust it.`)
);
};
export const weightsUnchangedReply = (position) => doc(
text(`I could not tell which weights to change on **${position.title}**.`),
note('Name the dimension and the number — "set experience to 40" — and I will write it.')
);
/* ── Job description ───────────────────────────────────────────────────── */
export const descriptionReply = (position, result) => doc(
heading('Job description', position.title),
text(result.description),
result.responsibilities?.length ? list(result.responsibilities) : null,
note('Saved to the position. Regenerate any time — it replaces what is there.')
);
export const descriptionFailedReply = (position) => doc(
text(`I could not generate a description for **${position.title}**.`),
note('Nothing was changed. Try again, or write it on the position itself.')
);
/* ── Publish ───────────────────────────────────────────────────────────── */
export const publishedReply = (position) => doc(
heading('Published', position.title),
text(`**${position.title}** is live on the Positions list. Applications can arrive against it and screening runs as they do.`)
);
export const publishFailedReply = (position) => doc(
text(`I could not publish **${position.title}**.`),
note('It is still a draft, with everything you entered intact.')
);
export const publishedFollowUp = (position) => [
{ label: 'Match candidates', prompt: `Who matches ${position.title}?` },
{ label: 'View position', route: `/admin/positions/${position.id}` },
];
/**
* Continuing a named draft, as the question the panel already answers.
*
* The Continue control on a Positions card resolves to this — one wording, so a
* card, a chip and a typed sentence all reach the same in-panel handler rather
* than the card growing a second path of its own.
*/
export const continueDraftRequest = (position) => ({
question: `Continue the ${position.title} draft`,
positionId: position.id,
});

View File

@@ -1,247 +0,0 @@
import { normalizeSection } from './uiConfig';
import {
SUPPORTED_OWLIVER_CAPABILITIES, SUPPORTED_SECTION_TYPES, owliverCapabilityFor,
} from './surfaces';
/**
* The `owliver:` block of a skill definition, checked and normalized.
*
* The same file that extends a page can extend the panel beside it. `ui:` says
* what the page renders; `owliver:` says what can be *asked for*, and both name
* the same data source — so the card and the answer are two readings of one
* declaration rather than two definitions that have to be kept in step.
*
* owliver:
* enabled: true
* suggestions:
* - Show hiring activity
* - Summarize hiring activity
* capabilities:
* - summary
* - flow
* responses:
* flow:
* title: Hiring Activity Flow
* source: position.activity
* steps: [today, yesterday, last-week]
*
* Three properties hold, and each one is a rule the rest of the system relies
* on:
*
* - **A response is a section.** It is normalized by the same function the
* page's `ui:` sections go through, so a capability resolves to the same
* record, the same data source and the same renderer. There is no second
* shape for "the chat version".
* - **The block is optional.** A definition with no `owliver:` normalizes to
* a disabled record and behaves exactly as it did before this existed.
* - **Nothing unknown survives.** Capabilities, sources, periods and shapes
* are checked against the closed vocabulary in `surfaces.js`; an
* unrecognised value is a named error rather than a dropped key.
*/
/** What a definition with no `owliver:` block gets. */
export const NO_OWLIVER = Object.freeze({
enabled: false,
suggestions: [],
capabilities: [],
responses: {},
});
/** The section type a capability is drawn with — `summary` draws nothing. */
const shapeOf = (capability) => owliverCapabilityFor(capability)?.shape || null;
/**
* The reading a response inherits when it does not name one.
*
* A skill that already declares a `ui:` section has stated its source once;
* making it state it again for the panel would be the format asking the author
* to repeat themselves, and would let the two drift apart. The first declared
* section wins, in declaration order.
*/
function inheritedSection(ui) {
for (const page of Object.values(ui || {})) {
const section = (page.sections || [])[0];
if (section) return section;
}
return null;
}
/** One suggestion, in either the plain-string or the mapping form. */
function normalizeSuggestion(raw, { errors, capabilities, index }) {
const where = `owliver.suggestions[${index}]`;
if (typeof raw === 'string' || typeof raw === 'number') {
const label = String(raw).trim();
if (!label) {
errors.push(`${where}: a suggestion needs text.`);
return null;
}
return { label, prompt: label, capability: null };
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
errors.push(`${where}: a suggestion must be a line of text, or a mapping of options.`);
return null;
}
const label = String(raw.label ?? raw.prompt ?? '').trim();
if (!label) {
errors.push(`${where}: a suggestion needs a \`label\`.`);
return null;
}
/* A suggestion may say which capability it asks for. That is what makes a
chip exact — the words are the author's, and the answer is not left to be
re-derived from them. */
const capability = raw.capability == null ? null : String(raw.capability).trim();
if (capability && !SUPPORTED_OWLIVER_CAPABILITIES.includes(capability)) {
errors.push(
`Unsupported Owliver capability: ${capability}. Supported capabilities: ${SUPPORTED_OWLIVER_CAPABILITIES.join(', ')}.`
);
return null;
}
if (capability && capabilities.length && !capabilities.includes(capability)) {
errors.push(`${where}: \`${capability}\` is not listed under \`owliver.capabilities\`.`);
return null;
}
return {
label,
prompt: String(raw.prompt ?? raw.label).trim(),
capability: capability || null,
};
}
/**
* The whole `owliver:` block, normalized.
*
* Returns `{ owliver, errors }`. As with `ui:`, what validates is kept and what
* does not is reported: a definition with one bad response still registers its
* good ones, and the author is told why the other was refused.
*/
export function normalizeSkillOwliver(raw, { ui = {}, skillId = '', skillName = '' } = {}) {
const errors = [];
if (raw == null) return { owliver: NO_OWLIVER, errors };
if (typeof raw !== 'object' || Array.isArray(raw)) {
return { owliver: NO_OWLIVER, errors: ['`owliver` must be a mapping of options.'] };
}
/* Declaring the block is the opt-in; `enabled: false` is how it is switched
off without deleting what was written. */
const enabled = raw.enabled !== false;
/* Capabilities may be listed, or left to be read off the responses — which is
what a definition that writes one response and nothing else means. */
const declared = Array.isArray(raw.capabilities)
? raw.capabilities.map((c) => String(c).trim()).filter(Boolean)
: [];
const responsesRaw = raw.responses && typeof raw.responses === 'object' && !Array.isArray(raw.responses)
? raw.responses
: {};
if (raw.responses != null && !Object.keys(responsesRaw).length) {
errors.push('`owliver.responses` must be a mapping of capability names to responses.');
}
const capabilities = [];
for (const capability of [...declared, ...Object.keys(responsesRaw)]) {
if (!SUPPORTED_OWLIVER_CAPABILITIES.includes(capability)) {
errors.push(
`Unsupported Owliver capability: ${capability}. Supported capabilities: ${SUPPORTED_OWLIVER_CAPABILITIES.join(', ')}.`
);
continue;
}
if (!capabilities.includes(capability)) capabilities.push(capability);
}
/* Suggestions are read after capabilities, so one naming a capability can be
checked against what the skill actually offers. */
const suggestionsRaw = raw.suggestions == null ? [] : raw.suggestions;
let suggestions = [];
if (!Array.isArray(suggestionsRaw)) {
errors.push('`owliver.suggestions` must be a list.');
} else {
suggestions = suggestionsRaw
.map((entry, index) => normalizeSuggestion(entry, { errors, capabilities, index }))
.filter(Boolean);
}
/* Every capability resolves to a section — declared, or inherited from the
page UI this skill already configures. A capability that can name no
reading is refused: it would otherwise register as something Owliver
offers and then have nothing to answer with. */
const inherited = inheritedSection(ui);
const responses = {};
const seen = new Set();
for (const capability of capabilities) {
const declaredResponse = responsesRaw[capability];
if (declaredResponse != null
&& (typeof declaredResponse !== 'object' || Array.isArray(declaredResponse))) {
errors.push(`owliver.responses.${capability}: expected a mapping of options.`);
continue;
}
const response = declaredResponse || {};
/* `steps:` is what a flow reads like in a definition; `periods:` is what
the rest of the format calls the same list. */
const periods = response.steps ?? response.periods ?? (declaredResponse ? null : inherited?.periods);
const source = response.data?.source ?? response.source ?? inherited?.source;
if (!source) {
errors.push(
`owliver.responses.${capability}: a response needs a \`source\`, or a \`ui:\` section to read from.`
);
continue;
}
const section = normalizeSection(
{
id: `${skillId || 'skill'}-${capability}`,
type: capability,
title: response.title ?? null,
description: response.description ?? null,
source,
periods: periods ?? [],
limit: response.limit ?? inherited?.limit ?? null,
/* Editing is a property of the capability, and is inherited from the
page section the same way the source is: a definition that made its
card adjustable meant the answer to be adjustable too, unless it
says otherwise. */
editable: response.editable ?? (declaredResponse ? false : inherited?.editable) ?? false,
},
{
errors,
seen,
where: `owliver.responses.${capability}`,
fallbackId: skillId,
placement: false,
types: [...SUPPORTED_SECTION_TYPES, 'summary'],
shapeFor: shapeOf,
}
);
if (!section) continue;
responses[capability] = {
...section,
capability,
/* The shape the answer is drawn with, resolved once here so no consumer
has to know that `summary` is the one capability with no component. */
shape: shapeOf(capability),
title: section.title || skillName || null,
};
}
return {
owliver: {
enabled,
suggestions,
/* Only capabilities that resolved to a reading are offered. */
capabilities: capabilities.filter((c) => responses[c]),
responses,
},
errors,
};
}

View File

@@ -1,416 +0,0 @@
import { OWLIVER_CAPABILITIES, dataSourceLabel, owliverCapabilityFor } from './surfaces';
import { skillsForContext } from './registry';
import { resolveSkillData } from './dataResolver';
import { resolvePosition } from './workforceFlow';
/**
* The Owliver half of a skill definition, resolved.
*
* The page reads a definition through `SkillSurface`; this is the other reader.
* Given the page you are on and what you asked, it answers three questions and
* nothing else:
*
* 1. which registered skills apply here,
* 2. which of them you are asking for, and which capability,
* 3. what the answer is, read from the source the definition names.
*
* The rule that makes this an architecture rather than a lookup table: **no
* skill is named here.** There is no `if (skill.id === …)`, no prompt string
* matched against a constant, and no component per skill. A definition is
* matched by what it declares — its triggers, its suggestions, its name, its
* description — and answered by the shape it declares. A skill written after
* this file was last edited resolves exactly as well as one written before it.
*/
/* ── Which skills apply ─────────────────────────────────────────────────── */
/**
* The Owliver-enabled skills registered for a page context.
*
* `skillsForContext` is the single answer to "what is attached here", already
* honouring `pages:`, `status:` and the account's switched-off list — so the
* page's sections and the panel's answers are filtered by one rule, and
* switching a skill off in Settings removes both at once.
*/
export function owliverSkillsForContext(contextId, disabled = [], customSources = []) {
return skillsForContext(contextId, disabled, customSources)
.filter((skill) => skill.owliver?.enabled && skill.owliver.capabilities.length > 0);
}
/* ── Suggestions ────────────────────────────────────────────────────────── */
/** How many chips one skill may contribute, and how many all of them may. */
const PER_SKILL = 3;
const TOTAL = 4;
/**
* The chips a page's skills offer.
*
* Capped deliberately. A workspace with six skills attached would otherwise
* bury the page's own suggestions under twenty of them, and a suggestion nobody
* can find is not a suggestion. A skill contributes its first few, and the set
* as a whole stays within what the composer can show without becoming a menu.
*
* A suggestion naming a capability the skill does not offer is dropped rather
* than shown and then refused.
*/
export function owliverSuggestions(contextId, disabled = [], customSources = [], context = {}) {
const chips = [];
for (const skill of owliverSkillsForContext(contextId, disabled, customSources)) {
/**
* Can this skill answer without asking a question back?
*
* A capability reading one position cannot say anything until it knows
* which — so on a page with nothing selected, clicking it produces a
* question rather than an answer. Those suggestions are still offered, but
* they are marked so the panel can rank them behind the ones that will
* actually answer. That is a property of the declared source, not of any
* particular skill: a definition added tomorrow reading one record is
* ranked the same way.
*/
const needs = (capability) => skill.owliver.responses[capability]?.context || null;
const met = (need) => !need
|| (need === 'positionId' && Boolean(context.position))
|| (need === 'candidateId' && Boolean(context.candidate));
const offered = skill.owliver.suggestions
.filter((s) => !s.capability || skill.owliver.capabilities.includes(s.capability))
.slice(0, PER_SKILL)
/* Deliberately not carried as `capability`: a chip with that field is one
of the *page's* own answers and bypasses routing entirely. A skill's
chip is an ordinary question, and is resolved the same way the same
words typed by hand would be — one path, so a chip can never answer
something the typed form would not. */
.map((s) => {
const need = needs(s.capability || skill.owliver.capabilities[0]);
return {
label: s.label,
prompt: s.prompt,
skillId: skill.id,
skillCapability: s.capability || null,
/* True when clicking this would have to ask which record first. */
deferred: !met(need),
};
});
chips.push(...offered);
}
/* Answerable suggestions first, then the ones that would ask a question
back — stable within each group, so a definition's own order is kept. */
const ready = chips.filter((c) => !c.deferred);
const asking = chips.filter((c) => c.deferred);
return [...ready, ...asking].slice(0, TOTAL);
}
/* ── Matching ───────────────────────────────────────────────────────────── */
const lower = (value) => String(value ?? '').toLowerCase();
/** Words worth matching on — the ones that carry the subject of a question. */
const STOP_WORDS = new Set([
'the', 'a', 'an', 'this', 'that', 'these', 'those', 'my', 'our', 'is', 'are', 'was', 'were',
'show', 'me', 'as', 'of', 'for', 'in', 'on', 'to', 'and', 'or', 'with', 'what', 'how', 'can',
'you', 'i', 'it', 'please', 'give', 'tell', 'about', 'here', 'now', 'current', 'currently',
]);
const words = (value) => lower(value).split(/[^a-z0-9]+/).filter((w) => w.length > 2 && !STOP_WORDS.has(w));
/**
* Does a declared trigger match? `*` stands for anything in between, the same
* way the registry's own trigger matching reads it.
*/
function triggerMatches(trigger, question) {
if (!trigger.includes('*')) return question.includes(trigger);
const pattern = trigger
.split('*')
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('[\\s\\S]{0,40}?');
return new RegExp(pattern).test(question);
}
/**
* How strongly a question asks for this skill.
*
* Evidence is weighted by how deliberate it is. A suggestion the author wrote
* and the reader clicked is the strongest signal there is; a declared trigger
* is next; the skill's own name is next; and shared words with its description
* are the weakest — enough to break a tie, never enough to win on their own.
*/
function scoreSkill(skill, question) {
const q = lower(question);
const suggestion = skill.owliver.suggestions.find((s) => lower(s.prompt) === q || lower(s.label) === q);
if (suggestion) return { score: 100, suggestion };
/**
* Deliberate evidence: the author said this skill answers this.
*
* A suggestion the reader is echoing, a declared trigger, or the skill's own
* name. One of these must hold before a definition may claim a question at
* all — see the floor below.
*/
let deliberate = 0;
if (skill.owliver.suggestions.some((s) => q.includes(lower(s.label)))) deliberate += 40;
if (skill.triggers.some((t) => triggerMatches(t, q))) deliberate += 30;
if (skill.name && q.includes(lower(skill.name))) deliberate += 20;
/**
* The floor. Sharing a word with a description is not a claim.
*
* This is the bug that let "create a position" be answered by a candidate
* matching skill: its description happened to contain the word "position",
* which scored three points, and three points beat nothing. Corroboration
* was being treated as evidence.
*
* So description overlap can now only *break a tie* between definitions that
* already named the subject, and can never qualify one on its own. A skill
* about candidates cannot claim a question about creating a role however its
* description happens to be worded — which is the general property, not a
* fix aimed at these two definitions.
*/
if (!deliberate) return { score: 0, suggestion: null };
const overlap = words(skill.description).filter((w) => q.includes(w)).length;
return { score: deliberate + Math.min(overlap * 3, 9), suggestion: null };
}
/** The capability a question asks for, from the shape words it uses. */
function scoreCapability(capability, question) {
const q = lower(question);
const definition = owliverCapabilityFor(capability);
if (!definition) return 0;
/* Longest matching term wins, so "as a flow" beats "flow" and a question
naming two shapes resolves to the more explicit one. */
return definition.terms.reduce(
(best, term) => (q.includes(term) && term.length > best ? term.length : best),
0
);
}
/**
* The skill and capability a question resolves to, or null.
*
* Both halves have to hold: a question that names no registered skill is not
* this system's to answer, and a skill matched with no capability falls back to
* the first one its definition declares — which is what makes "Show hiring
* activity" work without the author writing a trigger per shape.
*/
export function matchOwliverSkill(question, skills = []) {
let best = null;
for (const skill of skills) {
const { score, suggestion } = scoreSkill(skill, question);
if (score <= 0) continue;
if (!best || score > best.score) best = { skill, score, suggestion };
}
if (!best) return null;
const { skill, suggestion, score } = best;
const available = skill.owliver.capabilities;
/**
* `exact` means the question *is* a suggestion this definition published —
* the reader clicked a chip, or typed its words. It is the strongest claim
* anything can have on a question, and callers weighing this match against
* another matcher need to be able to see that rather than infer it from a
* number.
*/
const exact = Boolean(suggestion);
/* A chip that declared its capability has already answered this. */
if (suggestion?.capability && available.includes(suggestion.capability)) {
return { skill, capability: suggestion.capability, score, exact };
}
const asked = available
.map((capability) => ({ capability, weight: scoreCapability(capability, question) }))
.filter((c) => c.weight > 0)
.sort((a, b) => b.weight - a.weight)[0];
return { skill, capability: asked?.capability || available[0], score, exact };
}
/* ── The record a response is about ─────────────────────────────────────── */
/**
* The entity a source needs, resolved from the question and the page.
*
* Sources declare what they need — a position, a candidate, a draft, or
* nothing — and this is the one place that need is met. Three orders of
* evidence, most specific first:
*
* 1. the question named a record ("summarize hiring activity for Line Cook"),
* 2. the page has one open (the drawer, the form being filled in),
* 3. neither, and the answer has to ask.
*
* A source needing nothing resolves against the workspace and is always met.
*
* Naming the record wins over the page's selection deliberately: an admin who
* says which role they mean has said so, and answering about a different one
* because a drawer happened to be open would be worse than asking.
*/
export function resolveEntity(section, question, context = {}) {
const need = section.context;
if (!need) return { context, ok: true };
if (need === 'positionId') {
const named = resolvePosition(question, context.positions || [], null);
const position = named || context.position || null;
return position
? { context: { ...context, position }, ok: true }
: { ok: false, need: 'position' };
}
if (need === 'candidateId') {
const q = lower(question);
const named = (context.applications || []).find(
(a) => a.applicant_name && q.includes(lower(a.applicant_name))
);
const candidate = named || context.candidate || null;
return candidate
? { context: { ...context, candidate }, ok: true }
: { ok: false, need: 'candidate' };
}
return { context, ok: true };
}
/* ── The answer ─────────────────────────────────────────────────────────── */
/** The rows a reading offers, whatever shape its source returns them in. */
const rowsOf = (data) => data?.steps || data?.items || [];
/**
* One row, read back as a line.
*
* A reading carries a figure, a description of it, or both, depending on the
* source — so the line is assembled from what is there rather than from a fixed
* template, and a row with a description that already states its figure
* ("2 applications") does not repeat it.
*/
function rowLine(row) {
const label = String(row.label || row.title || row.id || '').trim();
if (!label) return null;
const value = row.value == null || row.value === '' ? null : String(row.value);
const detail = row.detail ? String(row.detail) : null;
const tail = detail
? (value && !detail.includes(value) ? `${detail} · ${value}` : detail)
: value;
return tail ? `${label} — ${tail}` : label;
}
/**
* A summary, built from whatever the source returned.
*
* Generic on purpose: it reads rows and states them. Nothing here knows what a
* period is, what a stage is, or which skill asked — which is precisely why a
* definition written tomorrow gets a summary without this function changing.
*/
export function summaryLines(data) {
return rowsOf(data).map((row) => rowLine(row)).filter(Boolean);
}
/** The label a reading is introduced by — the definition's words, then the source's. */
export const responseTitle = (skill, section) =>
section.title || `${skill.name} — ${dataSourceLabel(section.source)}`;
/**
* Everything an answer needs, resolved: the section, the data, and whether the
* page could supply the record the source required.
*
* Returns `{ skill, capability, section, data, missing }`. `missing` names what
* the caller must ask for; when it is null the reading is real and complete.
*/
export function resolveOwliverResponse({ skill, capability, question, context = {}, now = new Date() }) {
const section = skill.owliver.responses[capability];
if (!section) return null;
const entity = resolveEntity(section, question, context);
if (!entity.ok) {
return { skill, capability, section, data: null, missing: entity.need };
}
/* The same resolver the page's own sections go through, on the same
collections — so the panel and the card beside it cannot report different
figures for the same position. */
const data = resolveSkillData(section, entity.context, now);
return { skill, capability, section, data, missing: null, context: entity.context };
}
/**
* A reading, reduced to what a drawn section reads.
*
* A reply is kept in the thread, so it is stored: the records a source counted
* are the evidence behind a figure, not part of the answer, and writing every
* application into session storage to draw one bar would be paying for the
* whole dataset per turn. Nothing the renderers use is dropped.
*/
export function presentable(data) {
if (!data) return data;
const strip = ({ records, ...row }) => row;
return {
...data,
...(data.steps ? { steps: data.steps.map(strip) } : null),
...(data.items ? { items: data.items.map(strip) } : null),
};
}
/** Every capability the product understands, for the editor and the previews. */
export const CAPABILITY_SUMMARIES = OWLIVER_CAPABILITIES.map(
({ id, label, summary }) => ({ id, label, summary })
);
/* ── Suggestions for a record that has just appeared ────────────────────── */
/**
* What can now be asked about a record the conversation just produced.
*
* Creating a position is the moment "who could do this?" becomes worth asking,
* and the panel is the only thing that knows a position now exists. Rather than
* naming a skill to offer — which would put a candidate-matching feature inside
* the position-creation flow — this asks the registry the general question: of
* the skills attached to this page, which declare a capability whose reading is
* *about one position*? Those are exactly the ones that can say something about
* the record just made.
*
* The record's own title is appended to each prompt, so the answer resolves
* against it directly and the reader is never asked to pick from a list that
* includes the position they are looking at. Nothing is named here: a skill
* added tomorrow that reads a position is offered on the same terms.
*/
export function suggestionsForPosition(contextId, disabled = [], customSources = [], position) {
if (!position?.title) return [];
const chips = [];
for (const skill of owliverSkillsForContext(contextId, disabled, customSources)) {
/* Only capabilities that read one position — a workspace-wide reading has
nothing to do with the record that was just created. */
const scoped = skill.owliver.capabilities.filter(
(capability) => skill.owliver.responses[capability]?.context === 'positionId'
);
if (!scoped.length) continue;
const offered = skill.owliver.suggestions
.filter((s) => !s.capability || scoped.includes(s.capability))
.slice(0, 1)
.map((s) => ({
label: s.label,
/* Named, so the reading resolves against this position rather than
asking which one. */
prompt: `${s.prompt} for ${position.title}`,
skillId: skill.id,
skillCapability: s.capability || null,
}));
chips.push(...offered);
}
return chips.slice(0, 2);
}

View File

@@ -1,127 +0,0 @@
import { allSkills, getSkillsForPage } from './registry';
import { skillStateFor } from '@/lib/skillGraph';
/**
* Page attachment, resolved against the skill graph.
*
* Two systems meet here, and keeping them apart is the point:
*
* the definition a Markdown file in Forge — what the skill is, the rungs of
* its ladder, and the `pages:` it may be surfaced on
* the graph `lib/skillGraph.js` — what a given worker has actually
* verified, and how that scores against a position
*
* A page asks this module what to show, gets back definitions already paired
* with the reader's state, and renders. It never asks for a skill by name, and
* it never decides for itself whether a skill belongs to it — which is what
* makes a skill authored tomorrow appear tonight without a page being touched.
*
* Three ideas that are easy to conflate and must not be:
*
* skill.pages where this skill may be *surfaced*
* position.skill_requirements what a particular job *requires*
* profile.completed_courses what a person has actually *verified*
*
* A skill attached to `positions` does not make every position require it. It
* makes the Positions experience allowed to talk about it when a position
* happens to require it.
*/
/** Workforce definitions attached to a page, in the order Forge lists them. */
export const workforceSkillsForPage = (pageId, options = {}) =>
getSkillsForPage(pageId, { ...options, kind: 'workforce' });
/**
* The graph skill ids a page may surface. Callers gating an existing panel on
* attachment want this rather than the definitions themselves.
*/
export const skillIdsForPage = (pageId, options = {}) =>
new Set(workforceSkillsForPage(pageId, options).map((s) => s.skillId).filter(Boolean));
/**
* Is this capability allowed to be surfaced here?
*
* The gate for contextual additions to a page that already exists — a training
* recommendation on a position, say. `false` means render nothing, not render an
* explanation of why nothing rendered.
*/
export const isSkillOnPage = (skillId, pageId, options = {}) =>
skillIdsForPage(pageId, options).has(skillId);
/**
* What a page should show for one reader: every skill attached to it, paired
* with that person's state in it.
*
* Definitions whose `skill:` binding matches nothing in the graph are dropped
* rather than rendered as an empty ladder — a definition pointing at a
* capability the platform does not model is an authoring error, and showing it
* as "Not started" would hide that.
*
* Returns `[]` when nothing is attached. Callers render nothing at all in that
* case: a page with no skills attached should look like a page that was never
* given any, not like one whose skill section is empty.
*/
export function skillStatesForPage(pageId, courses = [], profile = null, options = {}) {
return workforceSkillsForPage(pageId, options)
.map((definition) => {
if (!definition.skillId) return null;
const state = skillStateFor(definition.skillId, courses, profile);
/* `skillStateFor` answers for any id; a ladder with no rungs means the
graph does not carry this capability. */
return state.ladder?.length ? { definition, state } : null;
})
.filter(Boolean);
}
/**
* Every training path the platform has, paired with one person's state in it.
*
* Page attachment is deliberately not consulted here. `pages:` says which
* *product surfaces* may talk about a path; a workforce management view is
* asking a different question — what training exists at all — and filtering it
* by attachment would quietly hide a path from the one screen whose job is to
* account for every one of them.
*
* Same definitions, same graph, same derivation as the page-scoped version
* above: this is a second question asked of one dataset, not a second dataset.
*/
export function workforceSkillStates(courses = [], profile = null, options = {}) {
const { customSources = [], disabled = [] } = options;
return allSkills(customSources)
.filter((s) => s.kind === 'workforce' && s.status === 'active' && !disabled.includes(s.id))
.map((definition) => {
if (!definition.skillId) return null;
const state = skillStateFor(definition.skillId, courses, profile);
/* A definition bound to a capability the graph does not carry is an
authoring error; showing it as "Not started" would conceal that. */
return state.ladder?.length ? { definition, state } : null;
})
.filter(Boolean);
}
/**
* The definition governing one capability, or null.
*
* Lets a component that already holds a graph skill id — a position requirement
* line, a match gap — reach the training path behind it, and with it the pages
* that path may be surfaced on.
*/
export function definitionForSkillId(skillId, { customSources = [] } = {}) {
if (!skillId) return null;
return allSkills(customSources).find(
(s) => s.kind === 'workforce' && s.status === 'active' && s.skillId === skillId
) || null;
}
/**
* The definition behind one capability, but only if this page may surface it.
*
* The common case for a contextual addition: a position requirement names a
* capability, and the page wants the training path for it *if* the author
* attached that path here. One call, so no component has to remember to check
* attachment separately and none can forget.
*/
export function pageDefinitionForSkillId(skillId, pageId, options = {}) {
const definition = definitionForSkillId(skillId, options);
return definition && definition.pages.includes(pageId) ? definition : null;
}

View File

@@ -1,512 +0,0 @@
import { doc, list, note, text } from '@/components/ai-assistant/blocks';
import { CERT_OPTIONS, ENGLISH_LEVELS, payLabel } from '@/lib/positionModel';
import {
buildPositionPrefill, extractCertifications, extractEnglish, extractExperience,
extractLocation, extractPay, extractRole,
} from './actions';
/**
* Creating a position, as a conversation.
*
* Owliver used to answer "create a bartender position" by opening the Create
* Position form with three fields filled in — which left the person who had just
* described the job in one sentence to type the rest of it into a drawer. The
* form was doing the asking, and the assistant was doing the paperwork.
*
* This module turns that round. The skill file lists the questions; this decides
* what each field name means — how an answer is read, whether it is already
* settled, and how it reads back in the summary. Same split as `actions.js`:
* Markdown declares, code interprets, and a field the code does not know is
* simply not asked about rather than being handled arbitrarily.
*
* The flow is a plain serializable object. It survives being written to
* sessionStorage between turns, and it holds no component state, so the
* conversation can be resumed exactly where it stopped.
*
* Nothing here writes. The last step returns a draft, and the panel creates the
* position with the same mutation the form uses.
*/
/* ── Reading answers ────────────────────────────────────────────────────── */
/** Title Case, for the free-text answers that name a proper noun. */
const titleCase = (value) => value.replace(/\b\w/g, (c) => c.toUpperCase());
/** A short free-text answer, stripped of the words around it. */
function cleanPhrase(answer, max = 60) {
const value = String(answer)
.trim()
.replace(/^(?:in|at|around|near|it is|its|it's)\s+/i, '')
.replace(/[.!?,;]+$/, '')
.replace(/\s+/g, ' ');
return value && value.length <= max ? value : null;
}
/** "Skip" is an answer to an optional question — it settles it, unanswered. */
const SKIP = /^(?:skip|skip this|skip it|no preference|not sure|does ?n(?:'|o)t matter|any|none of these)$/i;
/** A chip that means "let me type it" rather than an answer in itself. */
const FREE_TEXT = /^(?:other|another|custom|enter|enter .*|type .*|somewhere else)$/i;
/**
* What each field means: when it is already settled, how an answer is read, and
* how it reads back.
*
* `parse` returns a patch for the draft, or `null` when the answer was not
* understood — which re-asks with `retry` rather than storing a guess.
*/
const FIELDS = {
/**
* The client this role is being staffed for.
*
* A "client" is not a record of its own in this product — it is the `company`
* on the position, which is the field the Create Position form writes, the
* Positions card leads with, and Hired History reports against. So the
* conversation collects it into the same field rather than into a store of
* its own, and asking Owliver to create a client starts here.
*/
company: {
label: 'Company',
settled: (draft) => Boolean(String(draft.company || '').trim()),
retry: 'Type the client or company name — "Fairmont San Jose".',
parse: (answer) => {
const value = cleanPhrase(answer, 60);
return value && value.length > 1 ? { company: titleCase(value) } : null;
},
summary: (draft) => (draft.company ? `Company: ${draft.company}` : null),
},
role_category: {
label: 'Role',
settled: (draft) => Boolean(draft.title),
retry: 'Name the role — "bartender", "line cook", "event staff".',
parse: (answer, { roles }) => {
const role = extractRole(answer, roles) || cleanPhrase(answer, 40);
if (!role || role.length < 2) return null;
const known = roles.find((r) => String(r).toLowerCase() === role.toLowerCase());
/* A known category files the position; anything else becomes the title and
leaves the category at its default, exactly as the form behaves. */
return known
? { role_category: known, title: known }
: { title: titleCase(role) };
},
summary: (draft) => (draft.role_category && draft.role_category !== draft.title
? `${draft.title} · ${draft.role_category}`
: draft.title),
},
location: {
label: 'Location',
settled: (draft) => Boolean(draft.location),
retry: 'Type the city or area this role is based in.',
parse: (answer) => {
if (FREE_TEXT.test(String(answer).trim())) return null;
const value = extractLocation(answer) || cleanPhrase(answer);
return value ? { location: titleCase(value) } : null;
},
summary: (draft) => draft.location,
},
pay: {
label: 'Pay',
settled: (draft) => Number(draft.pay_range_min) > 0 || Number(draft.pay_range_max) > 0,
retry: 'Give a range like "$28–$36/hr", or a single rate.',
parse: (answer) => {
const pay = extractPay(answer);
return pay ? { pay_range_min: String(pay.min), pay_range_max: String(pay.max) } : null;
},
summary: (draft) => payLabel(draft),
},
min_experience_years: {
label: 'Experience',
settled: (draft) => draft.min_experience_years !== undefined && draft.min_experience_years !== null,
retry: 'How many years — or "no minimum".',
parse: (answer) => {
const years = extractExperience(answer);
if (years !== null) return { min_experience_years: years };
const bare = /^(\d{1,2})\s*\+?$/.exec(String(answer).trim());
return bare ? { min_experience_years: Number(bare[1]) } : null;
},
summary: (draft) => (draft.min_experience_years
? `${draft.min_experience_years}+ years experience`
: 'No minimum experience'),
},
english_required: {
label: 'English',
settled: (draft) => Boolean(draft.english_required),
retry: `One of ${ENGLISH_LEVELS.map((l) => l.label).join(', ')}.`,
parse: (answer) => {
const level = extractEnglish(answer)
|| ENGLISH_LEVELS.find((l) => l.label.toLowerCase() === String(answer).trim().toLowerCase())?.value;
return level ? { english_required: level } : null;
},
summary: (draft) => {
const level = ENGLISH_LEVELS.find((l) => l.value === draft.english_required);
return level ? `English: ${level.label}` : null;
},
},
certifications_required: {
label: 'Certifications',
settled: (draft) => Array.isArray(draft.certifications_required),
retry: 'Name a certification, or "none".',
parse: (answer) => {
const found = extractCertifications(answer);
if (found) return { certifications_required: found };
if (/^(?:none|no|no certifications?)$/i.test(String(answer).trim())) {
return { certifications_required: [] };
}
return null;
},
summary: (draft) => (draft.certifications_required?.length
? `Certifications: ${draft.certifications_required.join(', ')}`
: null),
},
};
/* ── Suggestions ────────────────────────────────────────────────────────── */
/**
* A step's chips.
*
* `@`-prefixed entries in the skill file resolve to the lists the application
* already owns, so the roles offered here are the roles the form offers and a
* certification added to the record's options appears in the conversation
* without this file or the skill file changing.
*/
function suggestionsFor(step, { roles }) {
const resolved = step.options.flatMap((option) => {
if (option === '@roles') return roles;
if (option === '@english') return ENGLISH_LEVELS.map((l) => l.label);
if (option === '@certifications') return CERT_OPTIONS;
return [option];
});
const capped = [...new Set(resolved)].slice(0, 6);
/* An optional question needs a way past it that is not a typed sentence. */
if (!step.required) capped.push('Skip');
return capped.map((label) => ({ label, prompt: label }));
}
/* ── Flow state ─────────────────────────────────────────────────────────── */
/** The steps a skill declares, keeping only the fields this module understands. */
const stepsOf = (skill) => (skill?.conversation || []).filter((s) => FIELDS[s.field]);
/** Is this field answered, or deliberately passed over? */
const isSettled = (flow, field) => FIELDS[field].settled(flow.draft) || flow.skipped.includes(field);
/** The next question, or `null` when there is nothing left to ask. */
function nextStep(flow, steps) {
if (flow.editing) return steps.find((s) => s.field === flow.editing) || null;
return steps.find((s) => !isSettled(flow, s.field)) || null;
}
/** Everything decided so far, one line each, in the order it is asked. */
function summaryLines(flow, steps) {
return steps
.filter((s) => isSettled(flow, s.field))
.map((s) => FIELDS[s.field].summary(flow.draft))
.filter(Boolean);
}
/** The required fields a position cannot be created without. */
const missingRequired = (flow, steps) => steps.filter((s) => s.required && !FIELDS[s.field].settled(flow.draft));
/* ── Replies ────────────────────────────────────────────────────────────── */
/** Ask the next question, or read the position back when there is none left. */
function ask(flow, steps, { roles, preamble = null, retry = null }) {
const step = nextStep(flow, steps);
if (!step) return review(flow, steps);
return {
flow: { ...flow, stage: 'collect', step: step.field },
doc: doc(
preamble ? text(preamble) : null,
preamble && summaryLines(flow, steps).length ? list(summaryLines(flow, steps)) : null,
text(step.question),
retry ? note(retry) : null
),
followUp: suggestionsFor(step, { roles }),
};
}
/** The whole position, before anything is written. */
function review(flow, steps) {
const missing = missingRequired(flow, steps);
/* Only reachable if a required answer was cleared — ask for it rather than
offering to create something incomplete. */
if (missing.length) {
return {
flow: { ...flow, stage: 'collect', step: missing[0].field, editing: null },
doc: doc(text(missing[0].question)),
followUp: [],
};
}
return {
flow: { ...flow, stage: 'review', step: null, editing: null },
doc: doc(
text('Ready to create this position?'),
list(summaryLines(flow, steps)),
note('Nothing is saved until you choose one. Save as Draft keeps it unpublished — the same as the button on the form.')
),
/* The two the form offers, in the same words and the same order, so the
conversation and the page commit a position the same two ways. */
followUp: [
{ label: 'Save as Draft', prompt: 'Save as draft' },
{ label: 'Publish Job Posting', prompt: 'Publish job posting' },
{ label: 'Change details', prompt: 'Change details' },
],
};
}
/** Which detail to change — the answers already given, as chips. */
function changeMenu(flow, steps) {
const settled = steps.filter((s) => isSettled(flow, s.field));
return {
flow: { ...flow, stage: 'change', step: null },
doc: doc(text('What should I change?')),
followUp: settled.map((s) => ({ label: FIELDS[s.field].label, prompt: FIELDS[s.field].label })),
};
}
/* ── Entry points ───────────────────────────────────────────────────────── */
/**
* Start collecting, from whatever the request already said.
*
* "Create a bartender position in Chennai paying $30–$40/hr" answers three
* questions before the first one is asked, and those are not asked again.
*/
export function beginPositionFlow({ question, skill, roles = [] }) {
const { prefill } = buildPositionPrefill(question, roles);
const steps = stepsOf(skill);
const flow = {
skillId: skill.id,
draft: prefill,
skipped: [],
editing: null,
stage: 'collect',
step: null,
};
const known = summaryLines(flow, steps);
return ask(flow, steps, {
roles,
preamble: known.length ? `Got it — here is what I have so far:` : null,
});
}
/**
* One answer, and whatever it makes the next thing to say.
*
* Returns `{ flow, doc, followUp }`, and on the confirmation step additionally
* `create: { draft }` — the panel writes it, this module does not.
*/
export function advancePositionFlow({ flow, answer, skill, roles = [] }) {
const steps = stepsOf(skill);
const said = String(answer).trim();
/* A way out that does not require finishing. `flow: null` ends it, and the
next question is answered by the page as usual. */
if (/^(?:cancel|stop|never ?mind|nevermind|forget it|quit|exit)$/i.test(said)) {
return {
flow: null,
doc: doc(
text('Stopped — nothing was created.'),
note('Ask me to create a position whenever you are ready.')
),
followUp: [],
};
}
/* The confirmation step. "Create position" is the only path to a record. */
if (flow.stage === 'review') {
/* Saving unpublished. The same write, with the status the form's own
"Save as Draft" button sets — one create path, two statuses. */
if (/^(?:save as draft|save draft|draft|save it as a draft)$/i.test(said)) {
if (missingRequired(flow, steps).length) return review(flow, steps);
return { flow: { ...flow, stage: 'creating' }, create: { draft: flow.draft, status: 'draft' } };
}
if (/^(?:create position|publish job posting|publish|create|create it|yes|confirm|looks good|go ahead)$/i.test(said)) {
if (missingRequired(flow, steps).length) return review(flow, steps);
return { flow: { ...flow, stage: 'creating' }, create: { draft: flow.draft, status: 'active' } };
}
if (/^(?:change|change details|edit|change something|no)$/i.test(said)) return changeMenu(flow, steps);
/* Anything else at the confirmation step is a correction stated outright —
"make it Bengaluru", "$32–$40". Read it against every field and apply
what it settles, rather than making the user find the menu. */
const revised = applyStatement(flow, steps, said, roles);
if (revised) return review(revised, steps);
return {
...review(flow, steps),
doc: doc(
text('I did not catch that. Ready to create this position?'),
list(summaryLines(flow, steps)),
note('Choose Save as Draft or Publish Job Posting, or tell me what to change.')
),
};
}
/* Choosing which detail to revisit. */
if (flow.stage === 'change') {
const target = steps.find((s) => FIELDS[s.field].label.toLowerCase() === said.toLowerCase());
if (!target) {
const revised = applyStatement(flow, steps, said, roles);
if (revised) return review(revised, steps);
return changeMenu(flow, steps);
}
return ask({ ...flow, editing: target.field }, steps, { roles });
}
/* Answering the question that was asked. */
const step = steps.find((s) => s.field === flow.step) || nextStep(flow, steps);
if (!step) return review(flow, steps);
const field = FIELDS[step.field];
if (SKIP.test(said) && !step.required) {
const next = { ...flow, skipped: [...flow.skipped, step.field], editing: null };
return ask(next, steps, { roles });
}
const patch = field.parse(said, { roles });
if (!patch) {
/* Not an answer to this question — but it may still be a fact about the
position ("in Chennai" while being asked for pay). Take it if so. */
const revised = applyStatement(flow, steps, said, roles);
if (revised) return ask(revised, steps, { roles });
return ask(flow, steps, { roles, retry: field.retry });
}
const next = {
...flow,
draft: { ...flow.draft, ...patch },
skipped: flow.skipped.filter((f) => f !== step.field),
editing: null,
};
/* A detail revisited from the change menu goes straight back to the summary
rather than walking the rest of the questions again. */
if (flow.editing) return review(next, steps);
return ask(next, steps, { roles });
}
/**
* A sentence read against every field at once.
*
* This is what makes the conversation forgiving: an answer that arrives out of
* order, or a correction stated rather than chosen from a menu, lands in the
* right field instead of being rejected for not answering the question asked.
* Returns an updated flow, or `null` when the sentence settles nothing.
*/
function applyStatement(flow, steps, said, roles) {
const { prefill } = buildPositionPrefill(said, roles);
const fields = new Set(steps.map((s) => s.field));
const patch = {};
/* Step names, not record fields — a step the sentence settled must come off
the skipped list so the summary shows it. */
const touched = new Set();
if (fields.has('role_category') && prefill.title) {
if (prefill.role_category) patch.role_category = prefill.role_category;
patch.title = prefill.title;
touched.add('role_category');
}
if (fields.has('location') && prefill.location) {
patch.location = prefill.location;
touched.add('location');
}
if (fields.has('pay') && prefill.pay_range_min) {
patch.pay_range_min = prefill.pay_range_min;
patch.pay_range_max = prefill.pay_range_max;
touched.add('pay');
}
if (fields.has('min_experience_years') && prefill.min_experience_years !== undefined) {
patch.min_experience_years = prefill.min_experience_years;
touched.add('min_experience_years');
}
if (fields.has('english_required') && prefill.english_required) {
patch.english_required = prefill.english_required;
touched.add('english_required');
}
if (fields.has('certifications_required') && prefill.certifications_required) {
patch.certifications_required = prefill.certifications_required;
touched.add('certifications_required');
}
if (!touched.size) return null;
return {
...flow,
draft: { ...flow.draft, ...patch },
skipped: flow.skipped.filter((f) => !touched.has(f)),
editing: null,
};
}
/* ── Outcomes ───────────────────────────────────────────────────────────── */
/** The position exists. Said plainly, with what was created. */
export function positionCreatedReply(position) {
const isDraft = position.status === 'draft';
return doc(
/* Draft and published are different outcomes, so they are named
differently: one was saved, the other went live. */
text(isDraft ? 'Saved as a draft.' : 'Position published successfully.'),
list([
position.company,
position.title,
position.location,
payLabel(position),
].filter(Boolean)),
note(isDraft
? 'It is on the Positions list as a draft — nobody can apply until it is published, and it stays a draft until you publish it.'
: 'It is on the Positions list now — applications will start appearing against it.')
);
}
/** The write failed. The draft is kept, so the answers are not lost. */
export function positionFailedReply() {
return doc(
text('I could not create that position.'),
note('Nothing was saved. Choose Create position to try again.')
);
}
/**
* What the panel offers after a position is created.
*
* A published role has an obvious next question — who can fill it — so it is
* offered here rather than left to be typed. The chip carries the position's
* own title and the wording the workforce engine already answers, so it is an
* ordinary question resolved by the path that was already there: no handler,
* no navigation, and the same answer as asking it by hand.
*/
export const createdFollowUp = (position) => (position.status === 'draft'
/**
* A saved draft offers no action, and that is the fix.
*
* This used to offer "Continue to save", routing back to the Create Position
* form. It made the completed state look unfinished: the reader had just been
* told the position was saved, and was immediately asked to save it again —
* by a button that put them back in the form they had just left. The write
* has happened, the record exists with `status: draft`, and the way to finish
* a draft later is its own card on the Positions list.
*/
? []
: [
{ label: 'View position', route: `/admin/positions/${position.id}` },
{ label: 'Match candidates', prompt: `Who matches ${position.title}?` },
]);

View File

@@ -1,859 +0,0 @@
import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement';
import { parseYaml } from './yaml';
import { normalizeSkillUi, slugify } from './uiConfig';
import { normalizeSkillOwliver } from './owliverConfig';
import {
SUPPORTED_SKILL_PAGES, canonicalPage, contextLabel, placementProvides, surfaceFor,
surfaceForRoute,
} from './surfaces';
/**
* Owliver skill registry.
*
* A skill is a Markdown file: frontmatter declares what it is and where it
* applies, the body documents what it can do. Nothing here executes Markdown —
* a skill names actions, and `actions.js` decides what those names mean. The
* split is the point: adding a capability is a file, not a change to Owliver.
*
* Registration is the filesystem. `import.meta.glob` picks up every file under
* `src/skills/`, so a new page's skill is registered by existing, and no
* component contains a route check to go with it.
*/
const FILES = import.meta.glob('/src/skills/**/*.md', { query: '?raw', import: 'default', eager: true });
/**
* Frontmatter, as data.
*
* Skills grew declarative UI configuration, which is nested, so this reads the
* YAML subset in `yaml.js` rather than the flat `key: value` pairs it used to.
* The old shapes are a strict subset of the new one — a definition written for
* the previous parser parses identically here.
*
* A file whose frontmatter cannot be read raises rather than registering a
* half-understood definition; `parseSkill` decides what to do with that.
*/
/**
* A definition's text, as the parser needs to see it.
*
* Files arrive from editors, from Windows, from copy-paste and from downloads,
* and four of the things they arrive with used to take the entire frontmatter
* block down: a UTF-8 byte-order mark before the opening fence, a blank line
* above it, `\r\n` line endings, and trailing spaces after `---`. In every one
* of those cases the fence did not match, `parseFrontmatter` returned an empty
* record, and the definition registered as `Untitled skill` with no pages —
* the file was read, and none of it was believed.
*
* None of this is a lenient parser: the YAML subset inside the fences is as
* strict as it ever was. This is only about recognising that a fence is a
* fence.
*/
export const normalizeDefinition = (raw) => String(raw ?? '')
.replace(/^\uFEFF/, '')
.replace(/\r\n?/g, '\n')
.replace(/^\s*\n+/, '');
/** Whether this text opens with a frontmatter block at all. */
export const hasFrontmatter = (raw) => /^---[ \t]*\n[\s\S]*?\n---[ \t]*(?=\n|$)/
.test(normalizeDefinition(raw));
export function parseFrontmatter(raw) {
const text = normalizeDefinition(raw);
const match = /^---[ \t]*\n([\s\S]*?)\n---[ \t]*(?=\n|$)/.exec(text);
if (!match) return { data: {}, body: text };
const data = parseYaml(match[1]);
return {
data: data && typeof data === 'object' && !Array.isArray(data) ? data : {},
body: text.slice(match[0].length).trim(),
};
}
/**
* The text under a `## Heading`, up to the next one.
*
* The heading is escaped before it becomes a pattern. It used to be
* interpolated raw, so a heading containing regular-expression punctuation —
* `## Capabilities (v2)` is the obvious one — compiled to a pattern that could
* not match the words it was built from, and the whole section silently read as
* absent. A section that is present and unreadable is the failure this parser
* must never have: it looks exactly like a section the author did not write.
*/
export function sectionSource(body, heading) {
const escaped = String(heading).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const section = new RegExp(`##\\s+${escaped}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i').exec(body);
return section ? section[1] : null;
}
/**
* List items under a `## Heading`, for the capability list shown in Settings.
*
* Two kinds of content were being dropped without a word, and both are the same
* mistake — treating "a line I do not recognise" as "a line that is not there":
*
* - **Wrapped items.** A bullet long enough to run onto a second line kept
* only its first line. `create-position.md` documented reading a request
* "out of a single sentence" and the registry held the sentence without its
* last three words. An item is now everything up to the next item or the
* blank line that ends the list.
* - **Numbered items.** Only `-` counted, so a `1.` list — the natural way to
* write ordered instructions — parsed as an empty section. Both markers are
* list items in Markdown and both are read as one here.
*
* Neither change alters any definition currently on disk: every one of them
* uses single-line `-` bullets, so this widens what can be written without
* moving what already was.
*/
const LIST_ITEM = /^\s*(?:-|\*|\d+[.)])\s+(.*)$/;
export function sectionBullets(body, heading) {
const source = sectionSource(body, heading);
if (source == null) return [];
const items = [];
for (const line of source.split(/\r?\n/)) {
const item = LIST_ITEM.exec(line);
if (item) {
items.push(item[1].trim());
continue;
}
/* A blank line closes the current item; anything else indented under one is
its continuation and belongs to it. Prose before the first item — the
explanatory paragraph `## Conversation` opens with — matches neither and
is ignored, exactly as before. */
if (!line.trim()) {
if (items.length) items.push('');
continue;
}
if (items.length && items[items.length - 1] !== '' && /^\s+/.test(line)) {
items[items.length - 1] += ` ${line.trim()}`;
}
}
return items.map((i) => i.trim()).filter(Boolean);
}
/**
* The questions a skill asks, from its `## Conversation` section.
*
* One bullet per question: `field | question | suggestions | required?`, where
* suggestions are separated by `;`. A skill that declares no conversation gets
* an empty list and behaves as it always did — this is additive.
*
* Nothing is executed and no suggestion is interpreted here. What a field name
* means, and what a suggestion resolves to, is `positionFlow.js`'s decision, the
* same way `actions.js` is the only place an action name means anything.
*/
function sectionSteps(body, heading) {
return sectionBullets(body, heading)
.map((line) => {
const [field, question, options = '', flag = ''] = line.split('|').map((p) => p.trim());
if (!field || !question) return null;
return {
field,
question,
options: options.split(';').map((o) => o.trim()).filter(Boolean),
required: !/^optional$/i.test(flag),
};
})
.filter(Boolean);
}
/**
* The prose under a `## Heading`, with its bullets and blank lines stripped to
* one line of summary. Used for a level's description, which is a sentence
* rather than a list.
*/
export function sectionText(body, heading) {
const source = sectionSource(body, heading);
if (source == null) return '';
return source
.split(/\r?\n/)
.map((l) => l.replace(/^\s*(?:[-*]|\d+[.)])\s+/, '').trim())
.filter(Boolean)
.join(' ')
.trim();
}
/**
* The rungs a workforce skill defines, read from its own body.
*
* A definition names its ladder as `## Beginner`, `## Intermediate` and so on,
* each followed by what a person must be able to do at that level. Only the
* headings that are actually present become rungs, so a skill that tops out at
* Advanced has a three-rung ladder rather than a fourth empty one — the ladder
* is what the author wrote, not a fixed shape they are padded into.
*/
const LEVEL_HEADINGS = ['Beginner', 'Intermediate', 'Advanced', 'Expert'];
function sectionLevels(body) {
return LEVEL_HEADINGS
.map((heading) => ({
level: heading.toLowerCase(),
label: heading,
summary: sectionText(body, heading),
}))
.filter((rung) => rung.summary);
}
/**
* The page key a route belongs to — `/admin/positions` → `positions`.
*
* The surface table answers first, because a surface already states its own
* route and its key is not always the path tail: `/admin/positions/new` is
* `create-position`, not `positions/new`. Falling back to the tail keeps every
* route that has no declared surface behaving exactly as it did.
*/
export function pageKeyForRoute(route) {
const surface = surfaceForRoute(route);
if (surface) return surface.id;
const tail = route.replace(/^\/admin\/?/, '');
return tail === '' ? 'control-center' : tail;
}
/** contextId → page key, for resolving skills from the assistant's context. */
const PAGE_KEY_BY_CONTEXT = Object.entries(PLACEMENT_ROUTES).reduce((acc, [route, contextId]) => {
acc[contextId] = pageKeyForRoute(route);
return acc;
}, {});
/** page key → route, so an action can navigate without hard-coding a path. */
const ROUTE_BY_PAGE_KEY = Object.entries(PLACEMENT_ROUTES).reduce((acc, [route, contextId]) => {
acc[pageKeyForRoute(route)] = { route, contextId };
return acc;
}, {});
export const routeForPageKey = (key) => ROUTE_BY_PAGE_KEY[key]?.route ?? null;
export const pageKeyForContext = (contextId) => PAGE_KEY_BY_CONTEXT[contextId] ?? null;
/**
* The two management surfaces a definition can belong to.
*
* `ui` extends a KROW page; `owliver` extends the assistant. They share the
* parser, the registry, the validator, the persistence and the data resolver —
* only the authoring and management experience is separate, which is what this
* classification serves.
*/
export const SKILL_FACETS = ['ui', 'owliver'];
/**
* Which of them a definition belongs to.
*
* Declared, never configured: a `ui:` block is a page extension, and an
* `owliver:` block, triggers, actions, a prompt or a conversation is an
* assistant extension. A definition that declares neither is an assistant
* skill — that is what every definition written before the split was, and
* reading it any other way would drop it out of both lists.
*
* Workforce paths are neither. They define a capability the workforce holds
* and are managed in Skill Development, so they carry no facet and appear on
* neither list.
*/
export function skillFacets({ data = {}, kind, ui = {}, owliver, conversation = [] }) {
if (kind === 'workforce') return [];
const extendsPage = Object.keys(ui).length > 0;
/**
* Behaviour Owliver actually gains: something to answer with, something to
* open, or questions to ask.
*
* Triggers alone are deliberately not on this list. A trigger is a way of
* being *named*, and a page-drawing definition that names itself is still a
* page-drawing definition — listing it as an Owliver skill would offer an
* author a capability list it never declared. A definition with no `ui:` is
* the other way round: triggers are all it has, and they are what it does.
*/
const teachesOwliver = Boolean(
data.owliver
|| (Array.isArray(data.actions) && data.actions.length)
|| data.prompt
|| conversation.length
|| owliver?.capabilities?.length
|| owliver?.suggestions?.length
);
return [
extendsPage ? 'ui' : null,
teachesOwliver || !extendsPage ? 'owliver' : null,
].filter(Boolean);
}
/**
* One Markdown definition → one skill.
*
* Shared by the files on disk and by anything added at runtime, so a skill
* written in the Add Skill dialog is parsed by exactly the same code as
* `create-position.md` and cannot drift into a second format.
*/
export function parseSkill(raw, { path = 'custom', custom = false } = {}) {
{
const { data, body } = parseFrontmatter(raw);
const declaredPages = Array.isArray(data.pages) ? data.pages : [];
/**
* The definition's id.
*
* `id:` when it is written, and it always wins — an explicit id is an
* address other definitions and stored preferences refer to, and deriving
* over the top of one would silently rename a skill.
*
* The fallback used to be the filename, which is right for a file in
* `src/skills/` and wrong for everything else: an uploaded definition is
* parsed with the placeholder path `custom`, so a file omitting `id:` was
* registered as the skill `custom` and the ID field filled in with the word
* "custom". Slugging the name is what an author means by leaving it out.
*/
const id = data.id || slugify(data.name) || path.split('/').pop().replace(/\.md$/, '');
const levels = sectionLevels(body);
/**
* Two things wear the same format.
*
* An *assistant* skill teaches Owliver to do something on a page. A
* *workforce* skill defines a capability the workforce can hold, at levels,
* and says which pages may surface it. A definition that names a ladder is
* the second kind — nothing else distinguishes them, so an author declares a
* workforce skill by writing one, not by setting a flag.
*/
const kind = data.kind || (levels.length ? 'workforce' : 'assistant');
/* The declarative UI this definition contributes, checked against the
closed vocabulary in `surfaces.js`. A definition with no `ui:` block is
exactly what it was before this existed. */
const { ui, errors: uiErrors } = normalizeSkillUi(data.ui, {
declaredPages,
skillId: id,
});
/**
* Where this definition applies.
*
* `pages:` when it is written. When it is not, the pages its `ui:` entries
* name — because a definition that says "put this on Positions and that on
* Analytics" has already declared its reach, and making it repeat the list
* above the block is the format asking twice. A definition that declares
* neither still has none, which is what `validateSkillSource` refuses on.
*/
const pages = declaredPages.length ? declaredPages : Object.keys(ui);
/* The same definition's second consumer. `owliver:` declares what can be
asked for in the panel, reading the source the page section already
names — so one file answers "what does this page show" and "what can
Owliver be asked here" without either being written twice. A definition
with no `owliver:` block is exactly what it was before this existed. */
const { owliver, errors: owliverErrors } = normalizeSkillOwliver(data.owliver, {
ui,
skillId: id,
skillName: data.name || '',
});
/* The questions this skill asks, when it collects its input in the chat
rather than by opening something. */
const conversation = sectionSteps(body, 'Conversation');
return {
id,
kind,
ui,
uiErrors,
owliver,
owliverErrors,
/**
* What this definition extends, derived from what it declares.
*
* Two things wear the same format and are managed as different lists: a
* definition with a `ui:` block extends a *page*, and one that teaches
* Owliver — an `owliver:` block, triggers, actions, a conversation —
* extends the *assistant*. Reading that off the declaration rather than
* off a `type:` field is what makes the split free: every definition
* already written classifies itself, nothing stored has to be migrated,
* and a definition doing both is listed in both places rather than
* losing half of itself to a category.
*/
facets: skillFacets({ data, kind, ui, owliver, conversation }),
name: data.name || 'Untitled skill',
description: data.description || '',
/**
* The family a definition belongs to, for grouping in a picker.
*
* Free text and entirely optional — a definition that omits it is
* uncategorized and behaves exactly as it did before this existed.
* Deliberately not a closed vocabulary: unlike a page or a data source,
* a category names nothing the runtime has to resolve, so constraining it
* would buy nothing and would make every new grouping a code change.
*/
category: typeof data.category === 'string' ? data.category.trim() : '',
status: data.status === 'inactive' ? 'inactive' : 'active',
pages,
/**
* The capability in the skill graph this definition governs.
*
* Declared as `skill:`, or inferred by dropping a `-training` suffix and
* swapping dashes for underscores — so `server-training.md` governs
* `server` and `customer-service-training.md` governs `customer_service`
* without the author restating it. Only meaningful for workforce skills.
*/
skillId: kind === 'workforce'
? (data.skill || id.replace(/-training$/, '')).replace(/-/g, '_')
: null,
/* The ladder, in order, each rung carrying what it means to hold it. */
levels,
/* The definition as written. Forge edits this; every other page reads the
parsed form, so there is exactly one artefact behind all of them. */
markdown: raw,
source: custom ? 'account' : 'repository',
/* The page label Settings shows, taken from the context the page carries
so the two never disagree. */
actions: Array.isArray(data.actions) ? data.actions : [],
/* A skill with no declared triggers answers to its own name, so a
definition that omits the field is still reachable by asking for it.
Explicit triggers replace the fallback rather than adding to it. */
triggers: (Array.isArray(data.triggers) && data.triggers.length
? data.triggers
: [data.name].filter(Boolean)
).map((t) => String(t).toLowerCase()),
/**
* Whether those triggers were *claimed* or merely inherited.
*
* The fallback above is convenient and, until this field existed,
* indistinguishable from the real thing — so a definition that only draws
* a card was silently claiming its own name as a phrase Owliver answers
* to, and could take a question from a definition written to answer it.
* Keeping the distinction lets the matcher weigh a claim differently from
* a default without changing what `triggers` contains.
*/
declaredTriggers: Boolean(Array.isArray(data.triggers) && data.triggers.length),
prompt: data.prompt || null,
capabilities: sectionBullets(body, 'Capabilities'),
purpose: sectionBullets(body, 'Purpose'),
conversation,
path,
body,
custom,
};
}
}
/** Every skill on disk, parsed once at module load. */
export const SKILLS = Object.entries(FILES)
.map(([path, raw]) => parseSkill(raw, { path }))
.sort((a, b) => a.name.localeCompare(b.name));
/**
* The registry as actually assembled, with everything that went wrong assembling
* it.
*
* This function exists because the three ways a definition could fail to arrive
* intact were all *silent*, and between them they are the whole of the
* "sometimes a skill only half works" report:
*
* 1. **A stored definition that will not parse was dropped.** The `catch`
* here returned `null` and the entry vanished. Nothing anywhere said a
* skill had been discarded, so the skill simply stopped answering and the
* registry looked healthy.
* 2. **A definition that parses can still register incomplete.**
* `normalizeSkillOwliver` keeps the capabilities that resolve and reports
* the rest as errors — correct, and the reason a single bad response does
* not cost an author their whole file. But only `validateSkillSource`
* reads those errors, and that runs in the authoring dialog. A definition
* arriving from storage was registered with its broken capability quietly
* missing, which is exactly "some checks work and others do not".
* 3. **A custom definition silently replaces a built-in of the same id.**
* Intended — it is how a shipped skill is overridden — but indistinguishable
* from the built-in having broken, because nothing reported the override.
*
* Why deployment exposed all three: custom definitions live in per-account
* `preferences`, not in the repository. The bundled `.md` files are identical
* everywhere — verified — so a definition that behaves differently in a
* deployed workspace differs because of what that *account* has stored, and
* because stored Markdown is validated when it is written and never again.
* Ship a change to the supported vocabulary and yesterday's valid definition
* silently loses a capability on next load.
*
* Nothing is dropped that used to register, and nothing new registers. The only
* change is that the failures now have somewhere to be read from.
*/
export function readSkillRegistry(customSources = []) {
const diagnostics = [];
const custom = [];
customSources.forEach((entry, i) => {
const path = entry?.path || `custom/${i}.md`;
let skill = null;
try {
skill = parseSkill(entry?.raw ?? entry, { path, custom: true });
} catch (error) {
diagnostics.push({
level: 'error',
kind: 'unreadable',
path,
skillId: null,
message: `A stored skill could not be read and is not registered. ${error?.message || ''}`.trim(),
});
return;
}
if (!skill?.id) {
diagnostics.push({
level: 'error',
kind: 'unreadable',
path,
skillId: null,
message: 'A stored skill has no `id` and is not registered.',
});
return;
}
custom.push(skill);
});
const byId = new Map(SKILLS.map((s) => [s.id, s]));
for (const skill of custom) {
if (byId.has(skill.id) && !byId.get(skill.id).custom) {
diagnostics.push({
level: 'warning',
kind: 'shadowed',
path: skill.path,
skillId: skill.id,
message: `\`${skill.id}\` replaces the built-in skill of the same id. The built-in definition is not registered.`,
});
}
byId.set(skill.id, skill);
}
const skills = [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
/* A registered definition that lost part of itself on the way in. Reported
for every skill, not only custom ones, so a file on disk that stops
resolving after a vocabulary change is just as visible. */
for (const skill of skills) {
for (const message of [...(skill.owliverErrors || []), ...(skill.uiErrors || [])]) {
diagnostics.push({
level: 'error',
kind: 'incomplete',
path: skill.path,
skillId: skill.id,
message,
});
}
}
/**
* A section that registered perfectly and can never draw anything.
*
* The same check `validateSkillSource` refuses on, run again at load — because
* a definition stored before the rule existed was validated under the old one
* and is never re-checked. Without this it stays in the workspace as a titled
* card reporting that it needs a record the page has no way of giving it, and
* the Skills page calls the workspace healthy.
*/
for (const skill of skills) {
for (const problem of unresolvableSections(skill)) {
diagnostics.push({
level: 'error',
kind: 'unresolvable',
path: skill.path,
skillId: skill.id,
message: problem.message,
});
}
}
/**
* Two definitions claiming one phrase on one page.
*
* Only the first is ever consulted, and "first" means alphabetically by name —
* so the losing definition answers nothing, with no way to tell that from it
* being switched off. Reported per page, because a shared trigger on two
* different pages is not a contest.
*/
const claims = new Map();
for (const skill of skills) {
if (skill.status !== 'active' || !skill.declaredTriggers) continue;
for (const page of skill.pages) {
const key = canonicalPage(page) || page;
for (const trigger of new Set(skill.triggers)) {
const at = `${key}::${trigger}`;
if (!claims.has(at)) claims.set(at, []);
claims.get(at).push(skill);
}
}
}
for (const [at, claimants] of claims) {
if (claimants.length < 2) continue;
const [page, trigger] = at.split('::');
diagnostics.push({
level: 'warning',
kind: 'trigger-collision',
path: claimants[0].path,
skillId: claimants[0].id,
message: `On ${page}, "${trigger}" is claimed by ${claimants.map((s) => s.id).join(' and ')}. `
+ `Only ${claimants[0].id} is consulted.`,
});
}
return { skills, diagnostics };
}
/**
* The built-in skills plus any the account has added.
*
* Custom definitions are stored as their Markdown source, so what is persisted
* is the same artefact a file on disk would be — nothing is half-parsed into a
* bespoke record. A custom skill sharing an id with a built-in replaces it,
* which is how one would be overridden without editing the repository.
*
* Unchanged in what it returns; `readSkillRegistry` is where the same work is
* done with its failures kept.
*/
export function allSkills(customSources = []) {
return readSkillRegistry(customSources).skills;
}
/** Everything that went wrong assembling the registry, for the Skills page. */
export function skillDiagnostics(customSources = []) {
return readSkillRegistry(customSources).diagnostics;
}
/**
* Sections that can never resolve where they are attached.
*
* A source declares the record it needs; a placement either hands one over or
* does not. Nothing compared the two, so the commonest authoring mistake in the
* product — `position.activity`, the Board editor's own default, on a page with
* no position — validated cleanly, registered, drew its title and then reported
* "This section needs a position to read" for good. The definition was never
* wrong about anything the product had told it to care about.
*
* Deliberately *only* the `ui:` half. An Owliver response with an unmet need is
* not a dead panel: `resolveEntity` asks which position is meant, and answers
* once told — which is why `hiring-activity-assistant` reads `position.activity`
* on Positions and works. A card cannot ask. That asymmetry is the reason one
* is refused and the other is left alone.
*/
export function unresolvableSections(skill) {
const problems = [];
for (const [page, config] of Object.entries(skill?.ui || {})) {
for (const section of config.sections || []) {
if (!section.context) continue;
if (placementProvides(page, section.placement).includes(section.context)) continue;
problems.push({
page,
placement: section.placement,
source: section.source,
message: `\`${section.source}\` needs ${contextLabel(section.context)} to read, and `
+ `\`${page}\` supplies none at \`${section.placement}\`. `
+ `Attach it to a placement that does, or read a source that needs nothing.`,
});
}
}
return problems;
}
/**
* Validates a definition before it is stored. Returns an error string or null.
*
* Frontmatter first, then the declarative UI — an author is told the first
* thing that is wrong, in the order they would fix it.
*/
export function validateSkillSource(raw) {
if (!String(raw).trim()) return 'Paste or upload a Markdown definition.';
let skill;
try {
skill = parseSkill(raw, { custom: true });
} catch (error) {
/* The YAML subset reports the line it failed on; that is far more useful
than "could not be parsed". */
return `That definition could not be parsed. ${error.message || ''}`.trim();
}
if (!skill.id) return 'The frontmatter needs an `id`.';
if (!/^[a-z0-9][a-z0-9-]*$/.test(skill.id)) return '`id` must be lower-case letters, numbers and dashes.';
if (!skill.name) return 'The frontmatter needs a `name`.';
if (!skill.pages.length) return 'The frontmatter needs at least one `pages` entry.';
const unknown = skill.pages.filter((p) => !surfaceFor(p));
if (unknown.length) {
return `Unsupported page${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}. Supported pages: ${SUPPORTED_SKILL_PAGES.join(', ')}.`;
}
/* A UI block that names something the product does not offer is refused
outright rather than registered with the offending section dropped. */
if (skill.uiErrors?.length) return skill.uiErrors[0];
/* Same rule for the panel half of the definition: a capability, source or
step the product cannot honour is refused now rather than registered as
something Owliver offers and then cannot answer. */
if (skill.owliverErrors?.length) return skill.owliverErrors[0];
/* A section attached where its source can never be read. See
`unresolvableSections` — this is the difference between a definition that
is wrong and one that merely looks right. */
const unresolvable = unresolvableSections(skill);
if (unresolvable.length) return unresolvable[0].message;
/**
* An Owliver block that can answer nothing.
*
* `owliver: enabled: true` with no capabilities is what the template produces
* before an author fills anything in. It registers, claims its own name as a
* trigger, can take a question from a definition written to answer it, and
* then reads its own description back. Refused here rather than saved and
* reported later as "the skill does not work".
*/
if (skill.owliver?.enabled
&& !skill.owliver.capabilities.length
&& !skill.conversation.length
&& !skill.actions.length) {
return 'This skill declares no capabilities, so Owliver could only read its description '
+ 'back. Add a capability, or remove the `owliver:` block.';
}
return null;
}
/** Page keys a skill may attach to, for the dialog's own guidance. */
export const PAGE_KEYS = Object.keys(ROUTE_BY_PAGE_KEY).sort();
/** Context ids a skill applies to, resolved through the placement table. */
export function contextIdsForSkill(skill) {
return skill.pages
.map((key) => ROUTE_BY_PAGE_KEY[key]?.contextId
|| ROUTE_BY_PAGE_KEY[pageKeyForRoute(surfaceFor(key)?.route || '')]?.contextId)
.filter(Boolean);
}
/**
* Every skill attached to a page — the one answer to "what belongs here".
*
* This is what `pages:` is for. A definition listing `positions` is not
* documenting itself; it is saying that the Positions experience may surface it,
* and this function is the only place that question is answered. Pages call it
* and render what comes back, which is what lets a skill authored tomorrow
* appear on the right pages tonight without any page component being edited.
*
* Nothing downstream may test a page name against a skill id. The moment a page
* asks "is this Server Training?" the attachment has stopped being data.
*/
export function getSkillsForPage(pageId, { disabled = [], customSources = [], kind } = {}) {
if (!pageId) return [];
const wanted = canonicalPage(pageId) || pageId;
return allSkills(customSources).filter(
(s) => s.status === 'active'
&& !disabled.includes(s.id)
&& s.pages.some((p) => (canonicalPage(p) || p) === wanted)
&& (!kind || s.kind === kind)
);
}
/**
* Skills available on a page.
*
* `disabled` is the account's list of switched-off skill ids, so a skill can be
* turned off from Settings without being deleted from disk.
*
* Assistant skills only: a workforce skill defines a capability, not something
* Owliver can be asked to do, and offering one as a chat action would promise
* behaviour that does not exist.
*/
export function skillsForContext(contextId, disabled = [], customSources = []) {
const pageKey = PAGE_KEY_BY_CONTEXT[contextId];
if (!pageKey) return [];
return getSkillsForPage(pageKey, { disabled, customSources, kind: 'assistant' });
}
/**
* Does one trigger match?
*
* A trigger is a phrase. `*` stands for "anything in between", which is what
* lets one line cover "create a position", "create a chef position" and
* "create a bartender position in Chennai" without listing every role — the
* roles come from data, so they must not be written into triggers.
*/
function triggerMatches(trigger, question) {
if (!trigger.includes('*')) return question.includes(trigger);
const pattern = trigger
.split('*')
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('[\\s\\S]{0,40}?');
return new RegExp(pattern).test(question);
}
/**
* The first skill on this page whose triggers match the question.
*
* A definition has to be *addressable by the assistant* before its triggers
* count, and there are two ways to be: teach Owliver something — an `owliver:`
* block, an action, a conversation — or explicitly claim a phrase with
* `triggers:`. A definition that does neither is a page extension that happens
* to have a name, and matching it here means answering a question with a
* restatement of a card's description.
*
* That was live: `hiring-activity` draws a flow on the Positions page, declares
* no triggers, and inherited "hiring activity" from its own name — enough to
* take "show hiring activity" from the definition written to answer it.
*
* Both conditions are read off what the definition declares, so a skill written
* tomorrow is admitted or excluded by the same rule, and nothing that claimed a
* phrase loses it.
*/
const addressable = (skill) => skill.declaredTriggers || skill.facets?.includes('owliver');
/**
* Every skill on this page whose triggers match — in registry order.
*
* `matchSkill` answers with the first and is what routing uses, because one
* question gets one answer. The full list is what makes the choice *auditable*:
* when two definitions claim the same phrase, the loser was previously
* invisible, and the winner was decided by `allSkills`'s alphabetical sort —
* a definition renamed from "Hiring…" to "Activity…" could take over a phrase
* without either file's triggers changing. Nothing here changes which skill
* answers; it makes the fact that there was a contest something a caller can
* see and report.
*/
export function matchSkills(question, contextId, disabled = [], customSources = []) {
const q = String(question).toLowerCase();
return skillsForContext(contextId, disabled, customSources).filter(
(s) => addressable(s) && s.triggers.length > 0 && s.triggers.some((t) => triggerMatches(t, q))
);
}
export function matchSkill(question, contextId, disabled = [], customSources = []) {
return matchSkills(question, contextId, disabled, customSources)[0] ?? null;
}
/**
* The definitions belonging to one management surface.
*
* The single answer to "what belongs on the UI Skills list" and "what belongs
* on the Owliver Skills list". Both lists come from `allSkills` — one registry,
* two readings of it — so a definition cannot exist on one list and be unknown
* to the other system.
*/
export const skillsWithFacet = (skills = [], facet) =>
skills.filter((s) => s.facets?.includes(facet));
/**
* AI agent skills — the ones Workspace & Skills governs.
*
* Two different things live in this registry, and they belong to two different
* products:
*
* - **AI agent skills** (`kind: 'assistant'`) are capabilities Owliver gains.
* An Owliver skill teaches it what it can be asked; a Board skill draws a
* section on a page. Both are governed in Workspace & Skills.
* - **Workforce training** (`kind: 'workforce'`) is what a *person* learns and
* proves — Bartending, Food Safety, Customer Service. That belongs to KROW
* Forge, and is read through `workforceSkillStates`.
*
* This exists so no surface has to reach for `allSkills()` and hope. A page that
* calls `allSkills()` gets both kinds, which is how five training paths ended up
* counted as Owliver capabilities on the Workspace landing page — the registry
* was right and the reading was not. Asking for agent skills by name means a
* training path added tomorrow cannot appear there by default.
*/
export const aiAgentSkills = (skills = []) => skills.filter((s) => s.kind === 'assistant');
/** Workforce training definitions — KROW Forge's half of the same registry. */
export const workforceTrainingSkills = (skills = []) =>
skills.filter((s) => s.kind === 'workforce');

View File

@@ -1,32 +0,0 @@
import { toast } from '@/components/ds';
/**
* Mutation options that tell the truth about whether a write survived.
*
* Every skill save reported success the moment the in-memory record changed,
* because that is all `updatePreferences` used to be able to report. A browser
* that refused the write — quota, private browsing, an eviction — produced a
* green toast, a populated list, and nothing at all after a reload. A definition
* an author spent ten minutes on disappeared with no event they could point at.
*
* The change is not made conditional on the write: it is real for this session
* either way, and refusing to apply it would be worse. What changes is that the
* author is told which of the two happened while they can still do something
* about it.
*/
export function reportSave(message) {
return {
/** @param {any} result */
onSuccess: (result) => {
if (result?.persisted === false) {
toast.error(
`${message} — but this browser would not store it, so it will be gone on reload. `
+ 'Export it from the Skills page first.'
);
return;
}
toast.success(message);
},
onError: () => toast.error('That could not be saved.'),
};
}

View File

@@ -1,595 +0,0 @@
import { hasFrontmatter, normalizeDefinition, parseFrontmatter, parseSkill } from './registry';
import { SUPPORTED_OWLIVER_CAPABILITIES, sourceSupportsOption } from './surfaces';
/**
* A definition, read into editor fields — and edited fields, written back.
*
* Two things used to be missing here, and between them they are the whole of
* "uploading a `.md` does nothing" and "the form fields do not work":
*
* - **Nothing read a file into the fields.** All three upload buttons set the
* Markdown and stopped, so an author who uploaded a complete definition was
* looking at a form full of empty boxes beside it. The two editors each had
* a reader that did exactly this job — `metaFromSource` and
* `draftFromSource` — and neither was reachable from the upload path. They
* live here now, so one file is read the same way wherever it arrives.
* - **Nothing wrote the fields back.** Both editors regenerated the whole
* definition from a template while the Markdown was untouched, and stopped
* the moment it was touched — which is every edit of an existing skill,
* because `touched` starts true there. Typing a new name updated React
* state that nothing saved. `patchFrontmatter` is the missing half: a field
* edits the key it owns, in place, and the body is never rewritten.
*
* Everything here reads the *registered* form of a definition — what
* `parseSkill` made of it — rather than the raw text. The form therefore shows
* what the product will actually do, including the placement an author left out
* and the vocabulary that filled it in.
*/
/* ── Reading a definition into fields ───────────────────────────────────── */
/**
* A definition, or nothing.
*
* `parseSkill` is deliberately forgiving: a file with no frontmatter at all
* still parses, taking its id from the path it was given. That is right for the
* registry, and wrong here — a text file dropped on the upload button would
* otherwise fill the ID field with `custom` and the name with `Untitled skill`,
* which reads as a definition the product understood. A form is filled from a
* definition or it is left alone.
*/
function readSkill(source) {
/* `hasFrontmatter`, not a second regex of this module's own. The two used to
be written out separately and drifted: a file the registry was willing to
read could be one this rejected, and rejecting it here empties a form the
preview beside it has just filled in. One detector, one answer. */
if (!hasFrontmatter(source)) return null;
return parseSkill(source, { custom: true });
}
/**
* A definition, cleaned up on the way in.
*
* Re-exported under the name the editors use, so an uploaded file is stored in
* the form every reader already agrees on rather than carrying a byte-order
* mark into `patchFrontmatter`, which would not recognise the fence and would
* write a second one above it.
*/
export const normalizeUpload = normalizeDefinition;
/** Whether a definition can be read into the fields at all. */
export const isReadableDefinition = (source) => hasFrontmatter(source);
/** The Board (UI) editor's fields, before a definition is loaded into them. */
export const EMPTY_BOARD_FIELDS = {
id: '', name: '', description: '', pages: [],
type: 'flow', placement: '', title: '', source: 'candidates.activity', periods: [],
};
/**
* The Owliver editor's fields, before a definition is loaded into them.
*
* `responses` is keyed by capability, because that is how the format is keyed
* and how the runtime reads it. It used to be one `source` and one `periods`
* shared by every selected capability, and that single field is the whole of
* the bug this shape exists to remove:
*
* - A capability selected before a source was picked composed **no**
* `responses:` block at all. `normalizeSkillOwliver` then dropped every
* capability for want of a source, `owliver.capabilities` normalized to
* `[]`, and `owliverSkillsForContext` excludes a skill with none — so the
* definition registered, showed as active on its page, and contributed no
* suggestion chip.
* - Two capabilities could not read different sources, and one global source
* that suited the first often could not be drawn as the second: `list`
* against `candidates.activity` is refused by `normalizeSection`, so
* picking it silently cost the author that capability.
*
* A form that cannot express what the format can is not a shortcut to it.
*/
export const EMPTY_OWLIVER_FIELDS = {
id: '', name: '', description: '', pages: [],
triggers: [], suggestions: [], capabilities: [], responses: {},
};
/**
* A Board skill, read back into the fields that compose one.
*
* The section comes from the registry's reading rather than from the Markdown
* text, so a definition that omitted `placement:` shows the placement it will
* actually render at.
*/
export function boardFieldsFromSource(source) {
try {
const skill = readSkill(source);
if (!skill) throw new Error('no frontmatter');
/* The first section, in declaration order — what the single-valued fields
can show. `pages` still carries every page the definition reaches, and
`uiIsEditableFromFields` is what stops those fields writing over the
ones this record cannot represent. */
const section = Object.values(skill.ui || {}).flatMap((p) => p.sections || [])[0];
return {
id: skill.id,
name: skill.name,
description: skill.description,
pages: skill.pages,
type: section?.type || EMPTY_BOARD_FIELDS.type,
placement: section?.placement || '',
/* The heading the card draws. Composed from the skill's name when it is
left out, but it is a field of its own in the format and a definition
that set it must survive a round trip through the form. */
title: section?.title || '',
source: section?.source || EMPTY_BOARD_FIELDS.source,
periods: section?.periods || [],
};
} catch {
return EMPTY_BOARD_FIELDS;
}
}
/**
* An Owliver skill, read back into the fields that compose one.
*
* Reads the resolved capabilities, which includes the ones whose source was
* inherited from a `ui:` block rather than written out — so the form shows what
* Owliver can be asked for, not what the file happened to spell.
*/
export function owliverFieldsFromSource(source) {
try {
const skill = readSkill(source);
if (!skill) throw new Error('no frontmatter');
/**
* Every capability the definition *declares*, not only the ones that
* resolved.
*
* `skill.owliver.capabilities` is filtered to those that found a reading,
* which is right for the runtime and wrong for a form: a stored definition
* declaring `summary` and `list` where `list` lost its source would open
* with `list` simply absent, and the next save would delete a capability
* its author never removed. The one that cannot answer is exactly the one
* they opened the editor to fix, so it is shown — checked, with no source,
* and refused by the same validation as before.
*
* Read from the frontmatter the definition actually carries, and narrowed
* to the closed vocabulary, so a typo is still not a capability.
*/
const declared = (() => {
let data;
try {
({ data } = parseFrontmatter(String(source)));
} catch {
return [];
}
const block = data?.owliver;
if (!block || typeof block !== 'object' || Array.isArray(block)) return [];
const listed = Array.isArray(block.capabilities) ? block.capabilities : [];
const keyed = block.responses && typeof block.responses === 'object' && !Array.isArray(block.responses)
? Object.keys(block.responses)
: [];
return [...listed, ...keyed]
.map((capability) => String(capability).trim())
.filter((capability) => SUPPORTED_OWLIVER_CAPABILITIES.includes(capability));
})();
/* Declaration order first, then anything that resolved without being
listed — a definition whose capability came from its `ui:` section. */
const capabilities = [...new Set([...declared, ...skill.owliver.capabilities])];
/**
* Every capability's own reading, not the first one's.
*
* Reading one and showing it against all of them is how a two-capability
* definition lost the second's source the first time any field was edited:
* the form wrote back what it had read, and it had read half the block.
*/
const responses = Object.fromEntries(
capabilities.map((capability) => {
const response = skill.owliver.responses[capability];
return [capability, {
source: response?.source || '',
periods: response?.periods || [],
limit: response?.limit ?? null,
}];
})
);
return {
id: skill.id,
name: skill.name,
description: skill.description,
pages: skill.pages,
/**
* Only the triggers the definition actually claimed.
*
* `skill.triggers` falls back to the skill's own name when none are
* declared, which is right for matching and wrong to put in a form: the
* field would fill with an inherited phrase, and the next edit would
* write it into the file as a declared one. That flips
* `declaredTriggers`, which the matcher weighs differently — and it would
* pin the *old* name's phrase the moment the skill is renamed.
*/
triggers: skill.declaredTriggers ? skill.triggers : [],
suggestions: skill.owliver.suggestions.map((s) => s.label),
capabilities,
responses,
};
} catch {
return EMPTY_OWLIVER_FIELDS;
}
}
/**
* Which management surfaces this definition belongs to, for the upload handoff.
*
* A file dropped into the Board editor that declares only an `owliver:` block
* is being edited in the wrong half of the product. Knowing that lets the
* editor offer the other one rather than showing a form none of whose fields
* apply.
*/
export function facetsFromSource(source) {
try {
return readSkill(source)?.facets || [];
} catch {
return [];
}
}
/**
* Which of the `ui:` shapes a definition uses.
*
* `shorthand` is one section applying to every declared page — the form the
* editors compose, and the only one whose four fields can represent the whole
* block. `list` and `per-page` can each hold several sections across several
* pages, so the fields would have to throw away everything but the first to
* write them back. Telling them apart is what lets the editor show a multi-page
* definition without being able to flatten it.
*/
export function uiShape(source) {
let data;
try {
({ data } = parseFrontmatter(String(source)));
} catch {
return 'none';
}
const ui = data?.ui;
if (!ui || typeof ui !== 'object') return 'none';
if (Array.isArray(ui)) return ui.length ? 'list' : 'none';
const SECTION_KEYS = ['type', 'source', 'data', 'placement', 'position', 'periods', 'title'];
return SECTION_KEYS.some((key) => key in ui) ? 'shorthand' : 'per-page';
}
/** Whether the section fields can write this definition's `ui:` block back. */
export const uiIsEditableFromFields = (source) => uiShape(source) !== 'list'
&& uiShape(source) !== 'per-page';
/**
* Whether this definition's pages come from its `ui:` block rather than a
* `pages:` key of its own.
*
* When they do, the Pages field is a read-out and must not be written back.
* Writing it would add a `pages:` list that agrees with the block today and
* silently governs it tomorrow: `normalizeSkillUi` refuses an entry naming a
* page that `pages:` does not list, so the next entry the author adds to the
* block would be rejected by a key they never wrote. A derived value is shown,
* not owned.
*/
export function pagesAreDerived(source) {
let data;
try {
({ data } = parseFrontmatter(String(source)));
} catch {
return false;
}
if (Array.isArray(data?.pages) && data.pages.length) return false;
return uiShape(source) !== 'none';
}
/* ── Writing fields back into the frontmatter ───────────────────────────── */
/**
* A scalar, written so the parser reads back what was meant.
*
* Quoted whenever the plain form would be read as something else — a value
* containing `:` or `#`, one with edge whitespace, or one that looks like a
* number, a boolean or null but is a string.
*/
function writeScalar(value) {
if (value === null || value === undefined) return 'null';
if (typeof value === 'boolean' || typeof value === 'number') return String(value);
const text = String(value);
const ambiguous = text === ''
|| /[:#]/.test(text)
|| text !== text.trim()
|| /^(true|false|null|~)$/.test(text)
|| /^-?\d+$/.test(text)
|| /^-?\d*\.\d+$/.test(text)
|| /^['"-]/.test(text);
return ambiguous ? `'${text.replace(/'/g, "''")}'` : text;
}
/**
* A value, as the block lines that follow its key.
*
* Returns `null` for a scalar, which is written on the key's own line instead.
* Only the subset in `yaml.js` is emitted — block maps and block sequences,
* nested to any depth — because that is the only subset the parser reads back.
*/
function writeBlock(value, indent) {
const pad = ' '.repeat(indent);
if (Array.isArray(value)) {
return value.map((item) => {
if (item && typeof item === 'object' && !Array.isArray(item)) {
const entries = Object.entries(item).filter(([, v]) => v !== undefined);
if (!entries.length) return `${pad}- {}`;
return entries
.map(([k, v], i) => {
const prefix = i === 0 ? `${pad}- ` : `${pad} `;
const nested = writeBlock(v, indent + 4);
return nested === null ? `${prefix}${k}: ${writeScalar(v)}` : `${prefix}${k}:\n${nested}`;
})
.join('\n');
}
return `${pad}- ${writeScalar(item)}`;
}).join('\n');
}
if (value && typeof value === 'object') {
return Object.entries(value)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => {
const nested = writeBlock(v, indent + 2);
return nested === null ? `${pad}${k}: ${writeScalar(v)}` : `${pad}${k}:\n${nested}`;
})
.join('\n');
}
return null;
}
/** The lines belonging to the key at `start`: everything indented under it. */
function blockEnd(lines, start, indent) {
let end = start + 1;
while (end < lines.length) {
const line = lines[end];
if (line.trim() === '' || /^\s*#/.test(line)) { end += 1; continue; }
if (line.match(/^\s*/)[0].replace(/\t/g, ' ').length <= indent) break;
end += 1;
}
/* Trailing blanks and comments belong to whatever comes next, not to this
key — a comment written above the following key must not be swallowed by
the block above it. */
while (end > start + 1 && lines[end - 1].trim() === '') end -= 1;
return end;
}
/** The index of `key` at `indent` within `[from, to)`, or -1. */
function findKey(lines, key, indent, from, to) {
const pattern = new RegExp(`^${' '.repeat(indent)}${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:`);
for (let i = from; i < to; i += 1) {
if (pattern.test(lines[i])) return i;
}
return -1;
}
/**
* One dotted path, set in a frontmatter line array.
*
* Scalars replace the value on their own line; anything else replaces the block
* beneath it. A key that is not there is appended to the end of its parent, so
* a definition that never declared `status:` gains one rather than being
* refused. Every line the path does not touch — including comments and key
* order — is left exactly as written, which is why this is a patch and not a
* re-serialisation.
*/
function setPath(lines, path, value) {
const parts = path.split('.');
let from = 0;
let to = lines.length;
let indent = 0;
for (let depth = 0; depth < parts.length - 1; depth += 1) {
const at = findKey(lines, parts[depth], indent, from, to);
/* A parent that does not exist is created empty, then descended into. */
if (at === -1) {
lines.splice(to, 0, `${' '.repeat(indent)}${parts[depth]}:`);
from = to + 1;
to = from;
indent += 2;
continue;
}
const end = blockEnd(lines, at, indent);
from = at + 1;
to = end;
indent += 2;
}
const key = parts[parts.length - 1];
const at = findKey(lines, key, indent, from, to);
const block = writeBlock(value, indent + 2);
const replacement = block === null
? [`${' '.repeat(indent)}${key}: ${writeScalar(value)}`]
: [`${' '.repeat(indent)}${key}:`, ...block.split('\n')];
if (at === -1) {
lines.splice(to, 0, ...replacement);
return replacement.length;
}
const end = blockEnd(lines, at, indent);
lines.splice(at, end - at, ...replacement);
return replacement.length - (end - at);
}
/**
* A definition with some frontmatter keys changed, and nothing else touched.
*
* `patch` is keyed by dotted path — `name`, `pages`, `ui.source`,
* `owliver.capabilities` — and a value of `undefined` leaves that path alone.
* `null` writes `null`; to remove a key, pass `REMOVE`.
*
* The body below the closing `---` is never read and never rewritten. That is
* the property that makes this safe to run on every keystroke: an author's
* prose, their comments and the order they wrote their keys in all survive a
* change to a field they did not write.
*/
export const REMOVE = Symbol('remove');
export function patchFrontmatter(source, patch = {}) {
/* Normalized first, for the same reason the parser is: a fence this does not
recognise is a fence it writes a second copy of. */
const raw = normalizeDefinition(source);
const match = /^---[ \t]*\n([\s\S]*?)\n---[ \t]*(?=\n|$)/.exec(raw);
/* No frontmatter to patch: give the definition one rather than silently
dropping the edit. */
if (!match) {
const lines = [];
for (const [path, value] of Object.entries(patch)) {
if (value === undefined || value === REMOVE) continue;
setPath(lines, path, value);
}
return `---\n${lines.join('\n')}\n---\n\n${raw.trim()}\n`;
}
const lines = match[1].split('\n');
for (const [path, value] of Object.entries(patch)) {
if (value === undefined) continue;
if (value === REMOVE) {
const parts = path.split('.');
const indent = (parts.length - 1) * 2;
/* Only a top-level or one-deep key is removable, which is every key the
editors own. */
let from = 0;
let to = lines.length;
if (parts.length > 1) {
const parent = findKey(lines, parts[0], 0, 0, lines.length);
if (parent === -1) continue;
from = parent + 1;
to = blockEnd(lines, parent, 0);
}
const at = findKey(lines, parts[parts.length - 1], indent, from, to);
if (at !== -1) lines.splice(at, blockEnd(lines, at, indent) - at);
continue;
}
setPath(lines, path, value);
}
return `---\n${lines.join('\n')}\n---${raw.slice(match[0].length)}`;
}
/* ── Writing fields back as one canonical patch ─────────────────────────── */
/**
* The frontmatter a set of Board fields means.
*
* Both composing paths go through this: a fresh draft applies it to an empty
* definition, an existing one applies it to the file already open. That is what
* makes "typed into the form" and "pasted as Markdown" the same artefact — the
* fields have one writer, so there is no second field set that only one path
* knows how to express.
*
* `existing` is the definition being edited, and only two questions are asked of
* it: whether its `pages:` are derived from its `ui:` entries, and whether its
* `ui:` block is one this form can represent at all. Both are refusals to
* overwrite what the fields cannot hold, never a second shape.
*/
export function boardPatch(fields, { existing = '' } = {}) {
const derived = existing ? pagesAreDerived(existing) : false;
const editable = existing ? uiIsEditableFromFields(existing) : true;
const section = editable ? {
'ui.type': fields.type || undefined,
'ui.placement': fields.placement || undefined,
'ui.title': fields.title || undefined,
'ui.source': fields.source || undefined,
/* Periods only where the reading has any. A source that counts nothing over
time keeps no period list, so switching to one cannot leave the previous
source's windows behind as a block that validates and does nothing. */
'ui.periods': fields.periods?.length && sourceSupportsOption(fields.source, 'periods')
? fields.periods
: REMOVE,
} : {};
return {
id: fields.id || undefined,
name: fields.name || undefined,
description: fields.description || undefined,
pages: fields.pages?.length && !derived ? fields.pages : undefined,
...section,
};
}
/**
* One capability's response, as the format writes it.
*
* Returns null when the capability has no source. That is deliberate and is the
* point of the whole change: an unconfigured capability is left out of
* `responses:` so `normalizeSkillOwliver` reports it by name and
* `validateSkillSource` refuses the save. The alternative — inventing a source
* to make the block well-formed — is how a definition gets saved reading data
* its author never chose.
*/
function responseFor(fields, capability) {
const response = fields.responses?.[capability];
const source = response?.source || '';
if (!source) return null;
const entry = { source };
if (response.periods?.length && sourceSupportsOption(source, 'periods')) {
entry.periods = response.periods;
}
if (response.limit && sourceSupportsOption(source, 'limit')) {
entry.limit = Number(response.limit);
}
return entry;
}
/**
* The frontmatter a set of Owliver fields means.
*
* Every selected capability that has a source is written with its *own*
* `source:`, and its own periods or limit where the reading takes them. Nothing
* is inherited from a `ui:` block here: this editor owns definitions that have
* no `ui:` block at all, and a response that relies on inheriting one silently
* loses its reading the day the section is edited.
*/
export function owliverPatch(fields, { existing = '' } = {}) {
const derived = existing ? pagesAreDerived(existing) : false;
const responses = {};
for (const capability of fields.capabilities || []) {
const response = responseFor(fields, capability);
if (response) responses[capability] = response;
}
return {
id: fields.id || undefined,
name: fields.name || undefined,
description: fields.description || undefined,
pages: fields.pages?.length && !derived ? fields.pages : undefined,
triggers: fields.triggers?.length ? fields.triggers : REMOVE,
'owliver.enabled': true,
'owliver.suggestions': fields.suggestions?.length ? fields.suggestions : REMOVE,
'owliver.capabilities': fields.capabilities?.length ? fields.capabilities : REMOVE,
/* An empty mapping is not a mapping the parser will take — `owliver.responses`
must be a mapping of capability names — so nothing configured removes the
key rather than writing a header with nothing under it. */
'owliver.responses': Object.keys(responses).length ? responses : REMOVE,
};
}
/**
* Which selected capabilities are not configured, by name.
*
* The editor shows these against the capability rather than only as a refusal
* on save, so "Summary needs a source" is read where the source is chosen. The
* *refusal* is still `validateSkillSource`'s, on the same definition the
* registry reads — this only says the same thing earlier.
*/
export const unconfiguredCapabilities = (fields) =>
(fields.capabilities || []).filter((capability) => !responseFor(fields, capability));

View File

@@ -1,896 +0,0 @@
/**
* What a skill definition is allowed to say about the product.
*
* A skill can extend a KROW page: name a surface, declare a section, and the
* page renders it. That only stays safe — and only stays a *product* rather
* than a scripting host — because the vocabulary is closed. Everything a
* definition may name is in this file: the surfaces, the placements on each
* surface, the component types, and the data sources.
*
* The rule that makes it safe: **nothing here is code, and nothing here is
* looked up dynamically from the file.** A definition names a key; this module
* says whether that key exists; the renderer maps it to a component the app
* already ships. A definition that names something absent is rejected with a
* message, never rendered as an unknown thing and never executed.
*/
/**
* The surfaces a skill can extend.
*
* `aliases` keep the page keys the existing skills already use — `hired`,
* `university` — working under the names the product now shows, so the eight
* definitions on disk did not have to be rewritten to gain this feature.
*/
export const SKILL_SURFACES = [
{
id: 'control-center',
label: 'Control Center',
route: '/admin',
placements: ['after-header', 'before-footer'],
provides: {},
},
{
id: 'positions',
label: 'Positions',
route: '/admin/positions',
/* Mounted in three places, because Positions is three experiences: the page
that lists the roles, the card for each one, and the drawer "View
position" opens.
`after-position-list-summary` and `after-position-list` are the *page*:
they render once, above and below the grid, with no position in context.
`after-position-card` renders inside each card. The rest render inside
the drawer and on the full position page, where one position is being
read. Every one of these is mounted — a placement the vocabulary offers
and no page provides is a definition that validates and then silently
does nothing. */
placements: [
'after-position-list-summary',
'after-position-list',
'after-header',
'after-position-card',
'after-position-summary',
'before-candidates',
'after-candidates',
'before-footer',
],
/**
* What each of those placements actually hands a section, read off the
* `<SkillSurface>` call sites rather than assumed from the surface.
*
* The distinction is the whole point: the two list placements render above
* and below the grid with no position in context, while every other one
* renders inside a card, a drawer or the position page and passes the
* record. A section reading `position.*` is answerable at one and not at
* the other, and until this was written down both validated identically.
*/
provides: {
'after-position-list-summary': [],
'after-position-list': [],
'after-header': ['positionId'],
'after-position-card': ['positionId'],
'after-position-summary': ['positionId'],
'before-candidates': ['positionId'],
'after-candidates': ['positionId'],
'before-footer': ['positionId'],
},
},
{
/* The authoring form, which is a surface in its own right: what a skill has
to say there is about the position being specified, not about the ones
that already exist. Its placements follow the form's own three parts, so
a definition can sit beside the field group it is about. */
id: 'create-position',
label: 'Create Position',
route: '/admin/positions/new',
aliases: ['new-position'],
placements: [
'after-header',
'after-job-description',
'after-vetting-weights',
'before-footer',
],
/* The draft in the form is the position, so every placement here supplies
one — which is what lets `position.vetting` be read and written while the
role is still being specified. */
provides: {
'after-header': ['positionId'],
'after-job-description': ['positionId'],
'after-vetting-weights': ['positionId'],
'before-footer': ['positionId'],
},
},
{
id: 'candidates',
label: 'Candidates',
route: '/admin/candidates',
placements: ['after-header', 'after-candidate-summary', 'before-footer'],
/* Only the summary placement renders against one person — it is mounted on
the candidate profile, not on the list. */
provides: { 'after-candidate-summary': ['candidateId'] },
},
{
id: 'hired-history',
label: 'Hired History',
route: '/admin/hired',
aliases: ['hired'],
placements: ['after-header', 'before-footer'],
/* `before-footer` is inside the record drawer; `after-header` is the page. */
provides: { 'before-footer': ['candidateId'] },
},
{
id: 'talent-pool',
label: 'Talent Pool',
route: '/admin/talent-pool',
placements: ['after-header', 'before-footer'],
provides: {},
},
{
id: 'krow-forge',
label: 'KROW Forge',
route: '/admin/university',
aliases: ['university', 'forge'],
placements: ['after-header', 'before-footer'],
provides: {},
},
{
id: 'analytics',
label: 'Analytics',
route: '/admin/analytics',
placements: ['after-header', 'before-footer'],
provides: {},
},
{
id: 'activity',
label: 'Activity',
route: '/admin/activity',
placements: ['after-header', 'before-footer'],
provides: {},
},
/* Not in the eight product surfaces, but skills already attach to it and the
account page reads them. Kept so nothing that works today stops working. */
{
/**
* Agent configuration.
*
* A surface so the page has a name the registry can resolve, and
* deliberately with **no placements**: nothing renders skill cards here, and
* a `ui:` skill that tried to attach would be refused rather than validating
* and then drawing nothing. No skill declares it, so an agent configured
* here still reaches no operational data.
*/
id: 'workspace-agent-configure',
label: 'Agent Configure',
route: '/admin/workspace/agents/new',
placements: [],
provides: {},
},
/**
* The rest of the workspace, and Settings.
*
* Named for exactly one reason: a page needs a key before an agent can say it
* covers it, and Owliver needs an agent before it can answer anywhere. These
* are configuration surfaces — they hold no workforce records — so like Agent
* Configure every one of them declares **no placements**, and no skill
* declares them. A `ui:` skill that tried to attach is refused at validation
* rather than validating and drawing nothing, and `skillsForContext` returns
* an empty list here, which is the honest answer.
*
* Being named is what makes the general agent's fallback architectural: it
* covers every surface in this table, so a page without a specialist has an
* agent rather than nothing. `skill-check` asserts that coverage, so adding a
* surface here and forgetting the agent fails the build rather than quietly
* producing a dead panel.
*/
{
id: 'settings',
label: 'Settings',
route: '/admin/settings',
placements: [],
provides: {},
},
{
id: 'workspace',
label: 'Workspace',
route: '/admin/workspace',
placements: [],
provides: {},
},
{
id: 'workspace-agents',
label: 'Agents',
route: '/admin/workspace/agents',
placements: [],
provides: {},
},
{
id: 'workspace-skills',
label: 'Skills',
route: '/admin/workspace/skills',
placements: [],
provides: {},
},
{
/* The skill editors, both of them. The same screen in the sense that
matters here: a definition is being written, and nothing on it is a
workforce record. */
id: 'workspace-skill-configure',
label: 'Skill Configure',
route: '/admin/workspace/skills/new',
placements: [],
provides: {},
},
{
id: 'skill-development',
label: 'Skill Development',
route: '/admin/workspace/skill-development',
placements: [],
provides: {},
},
{
id: 'profile',
label: 'Profile',
route: '/admin/profile',
placements: ['after-header', 'before-footer'],
provides: {},
},
{
id: 'candidates-analysis',
label: 'Candidate Analysis',
route: '/admin/candidates-analysis',
placements: ['after-header', 'before-footer'],
provides: {},
},
];
const BY_KEY = new Map();
for (const surface of SKILL_SURFACES) {
BY_KEY.set(surface.id, surface);
for (const alias of surface.aliases || []) BY_KEY.set(alias, surface);
}
/** Every name a definition may use for a surface, for error messages. */
export const SUPPORTED_SKILL_PAGES = SKILL_SURFACES.map((s) => s.id);
/**
* The surface a declared page name refers to, or null.
*
* Matched on the normalized name, not the literal one. A definition writing
* `Positions` or `Talent Pool` — which is how the page is spelled everywhere in
* the product — used to resolve to nothing, and a page name that resolves to
* nothing takes the whole `ui:` block with it. The keys stay canonical; only
* what an author may type to reach them widens.
*/
const normalizeKey = (page) => String(page ?? '')
.trim()
.toLowerCase()
.replace(/[\s_]+/g, '-');
export const surfaceFor = (page) => BY_KEY.get(normalizeKey(page)) || null;
/**
* Placement names a definition may use, beyond the canonical ones.
*
* A placement is a position in a page's layout, and the canonical names are
* written from the page's point of view — `after-position-card`. Authors write
* them from the definition's: `grid-card` is where it goes, `panel` is what it
* looks like. An alias only ever resolves to a placement the surface really
* offers, so the vocabulary stays exactly as closed as it was; what changes is
* how many ways there are to name a member of it.
*/
const PLACEMENT_ALIASES = {
'grid-card': 'after-position-card',
card: 'after-position-card',
panel: 'after-header',
top: 'after-header',
header: 'after-header',
footer: 'before-footer',
bottom: 'before-footer',
list: 'after-position-list',
summary: 'after-position-summary',
};
/**
* The canonical placement a declared name refers to on this surface, or null.
*
* An alias that points at a placement this surface does not offer resolves to
* null rather than to some other surface's placement — `panel` means
* `after-header` where there is one and nothing where there is not.
*/
export function placementFor(page, placement) {
const surface = surfaceFor(page);
if (!surface) return null;
const declared = normalizeKey(placement);
if (!declared) return null;
if (surface.placements.includes(declared)) return declared;
const aliased = PLACEMENT_ALIASES[declared];
return aliased && surface.placements.includes(aliased) ? aliased : null;
}
/** The canonical id for a declared page name — `hired` → `hired-history`. */
export const canonicalPage = (page) => surfaceFor(page)?.id || null;
/**
* What a placement hands a section, as the page actually mounts it.
*
* A source declares the record it needs — a position, a candidate, or nothing —
* and until this existed nothing compared that need against the pages a
* definition named. So a section reading `position.activity` could be attached
* to Analytics, validate cleanly, register, draw its title, and then report
* "This section needs a position to read" forever. That is the whole of the
* "the skill saved but does nothing" report, and it is an authoring mistake the
* product can catch rather than one the reader has to discover.
*
* Read per *placement*, never per surface: the Positions grid renders one
* section inside each card, with a position, and two more above and below the
* grid, with none. Both are `positions`.
*
* With no placement named, a definition is answered for the surface's default —
* `surface.placements[0]`, which is what `normalizeSection` fills in.
*/
export function placementProvides(page, placement = null) {
const surface = surfaceFor(page);
if (!surface) return [];
const at = String(placement || '').trim() || surface.placements[0];
return surface.provides?.[at] || [];
}
/**
* Everything the declared pages can supply at this placement, deduplicated.
*
* One page supplying a position is enough — a definition naming several pages
* is saying it belongs on all of them, and refusing it because one cannot
* answer would refuse the definition for the pages that can. The load-time
* diagnostic is where the partial case is reported.
*/
export function contextSuppliedBy(pages = [], placement = null) {
return [...new Set(
(Array.isArray(pages) ? pages : [pages])
.flatMap((page) => placementProvides(page, placement))
)];
}
/** "a position" / "a candidate", for a message an author can act on. */
export const contextLabel = (need) => (
need === 'positionId' ? 'a position' : need === 'candidateId' ? 'a candidate' : 'nothing'
);
/**
* The surface an Admin route belongs to — `/admin/positions/new` →
* `create-position`.
*
* The surfaces already carry their routes, so this reads the answer off the
* table rather than deriving a page key from the path a second time. That
* matters for the surfaces whose key is not their path tail: without it,
* `/admin/positions/new` would key as `positions/new`, which is a page nothing
* declares and no definition could attach to.
*/
export const surfaceForRoute = (route) =>
SKILL_SURFACES.find((s) => s.route === String(route || '').trim()) || null;
/**
* The section types a definition may ask for.
*
* Each entry names a component the application already ships. A type is a key
* in this table and nothing else: there is no path from a definition to a
* component that is not listed here, which is what stops `type:` from being an
* import statement in disguise.
*/
export const SECTION_TYPES = [
{ id: 'card', label: 'Card', summary: 'A titled panel of prose and figures.' },
{ id: 'stats', label: 'Stats', summary: 'A row of counted figures.' },
{ id: 'list', label: 'List', summary: 'A ranked or plain list of records.' },
{ id: 'timeline', label: 'Timeline', summary: 'Dated events, most recent first.' },
{ id: 'flow', label: 'Flow', summary: 'A sequence of stages or periods.' },
{ id: 'table', label: 'Table', summary: 'Rows and columns.' },
{ id: 'progress', label: 'Progress', summary: 'Bars against a total.' },
{ id: 'insight', label: 'Insight', summary: 'One finding, stated plainly.' },
/* Weighted criteria that share a budget. Distinct from `progress`, which is
bars against an independent maximum: these bars are shares of one total,
and the total is a fact about the set rather than about any one row. */
{ id: 'weights', label: 'Weights', summary: 'Weighted criteria as shares of one total.' },
];
export const SUPPORTED_SECTION_TYPES = SECTION_TYPES.map((t) => t.id);
/**
* What a definition may ask *Owliver* to do with the same reading.
*
* A skill declares `ui:` for the page and `owliver:` for the panel, and both
* name the same data source. A capability is the second half of that: the shape
* the answer takes when it is asked for in conversation rather than rendered on
* the page.
*
* `shape` is the section type the answer is drawn with, so `flow` in a chat
* reply is the *same* component the page renders — there is one flow renderer,
* not one per consumer. `summary` has no shape because a summary is prose: the
* figures are read back as sentences rather than drawn.
*
* `terms` are how a question is recognised as asking for this shape. They are
* deliberately about the *shape* and never about a subject: "as a flow" belongs
* here, "hiring activity" belongs in a definition's `triggers`. That split is
* what keeps this table closed while the skills stay open.
*/
export const OWLIVER_CAPABILITIES = [
{
id: 'summary',
label: 'Summary',
shape: null,
summary: 'Reads the figures back as sentences.',
terms: ['summary', 'summarise', 'summarize', 'summarised', 'summarized', 'summarising',
'summarizing', 'sum up', 'recap', 'overview', 'brief me', 'in short', 'tell me about',
'what is the', 'how is'],
},
{
id: 'flow',
label: 'Flow',
shape: 'flow',
summary: 'Draws the stages or periods as a sequence.',
terms: ['flow', 'as a flow', 'chart', 'graph', 'diagram', 'funnel', 'stages', 'visual',
'visualise', 'visualize', 'step by step'],
},
{
id: 'stats',
label: 'Stats',
shape: 'stats',
summary: 'A row of counted figures.',
terms: ['stats', 'statistics', 'figures', 'numbers', 'counts', 'how many'],
},
{
id: 'list',
label: 'List',
shape: 'list',
summary: 'A ranked or plain list of records.',
terms: ['list', 'who are', 'which ones', 'show me the records'],
},
{
id: 'table',
label: 'Table',
shape: 'table',
summary: 'Rows and columns.',
terms: ['table', 'as a table', 'rows', 'grid', 'spreadsheet'],
},
{
id: 'timeline',
label: 'Timeline',
shape: 'timeline',
summary: 'Dated events, most recent first.',
terms: ['timeline', 'history', 'chronology', 'over time', 'what happened'],
},
{
id: 'progress',
label: 'Progress',
shape: 'progress',
summary: 'Bars against a total.',
terms: ['progress', 'bars', 'completion', 'how far'],
},
{
id: 'weights',
label: 'Weights',
shape: 'weights',
summary: 'The weighted criteria, adjustable when the page accepts the write.',
terms: ['weight', 'weights', 'weighting', 'weightings', 'importance', 'balance',
'set the weights', 'adjust the weights', 'screening weight', 'vetting weight',
'criteria'],
},
{
id: 'insight',
label: 'Insight',
shape: 'insight',
summary: 'One finding, stated plainly.',
terms: ['insight', 'finding', 'takeaway', 'headline', 'what stands out'],
},
{
id: 'card',
label: 'Card',
shape: 'card',
summary: 'A titled panel of figures.',
terms: ['card', 'panel', 'at a glance'],
},
];
export const SUPPORTED_OWLIVER_CAPABILITIES = OWLIVER_CAPABILITIES.map((c) => c.id);
/** The capability a declared name refers to, or null. */
export const owliverCapabilityFor = (id) =>
OWLIVER_CAPABILITIES.find((c) => c.id === String(id || '').trim()) || null;
/** `flow` → `Flow`. */
export const owliverCapabilityLabel = (id) => owliverCapabilityFor(id)?.label || id;
/**
* The data a section may ask for.
*
* Each source is a named reading of data the application already holds, and
* `context` says what a page must know for the reading to be possible — a
* position id, a candidate id, or nothing. A source is resolved by
* `dataResolver.js`; a definition cannot reach a store directly, cannot write,
* and cannot name a field that is not offered here.
*/
export const DATA_SOURCES = [
{
id: 'position.activity',
label: 'Position activity',
context: 'positionId',
summary: 'Applications to this position, counted over time.',
shapes: ['flow', 'stats', 'timeline', 'table', 'insight', 'card'],
options: ['periods'],
},
{
id: 'position.pipeline',
label: 'Position pipeline',
context: 'positionId',
summary: 'Applied → screened → shortlisted → interviewed → hired.',
shapes: ['flow', 'stats', 'progress', 'table', 'card'],
},
{
id: 'position.candidates',
label: 'Position candidates',
context: 'positionId',
summary: 'Candidates matched to this position, best first.',
shapes: ['list', 'table', 'stats', 'card'],
options: ['limit'],
},
{
/**
* Who could actually do this work, ranked.
*
* Distinct from `position.candidates`, which lists the people who *applied
* here*. This one reads the whole candidate pool against what the position
* states — so a role published a minute ago, with no applications at all,
* still has an answer. The reading is `poolFor` in `lib/workforce.js`, the
* same engine the panel's workforce conversation and the position page use;
* nothing about matching is decided in the resolver.
*/
id: 'position.matches',
label: 'Position candidate matches',
context: 'positionId',
summary: 'The candidate pool scored against this position, best first.',
shapes: ['list', 'table', 'stats', 'card', 'insight'],
options: ['limit'],
},
{
id: 'position.requirements',
label: 'Position requirements',
context: 'positionId',
summary: 'What this position states it needs.',
shapes: ['list', 'table', 'card'],
},
{
id: 'candidate.readiness',
label: 'Candidate readiness',
context: 'candidateId',
summary: 'Screening dimensions for one candidate.',
shapes: ['progress', 'stats', 'list', 'table', 'card'],
},
{
id: 'candidate.activity',
label: 'Candidate activity',
context: 'candidateId',
summary: 'What has happened on this candidate’s record.',
shapes: ['timeline', 'list', 'table', 'card'],
},
{
id: 'candidates.pipeline',
label: 'Candidate pipeline',
context: null,
summary: 'Every candidate, counted by stage.',
shapes: ['flow', 'stats', 'progress', 'table', 'card'],
},
{
id: 'candidates.activity',
label: 'Candidate activity',
context: null,
summary: 'Applications across the workspace, counted over time.',
shapes: ['flow', 'stats', 'timeline', 'table', 'card'],
options: ['periods'],
},
{
id: 'positions.demand',
label: 'Position demand',
context: null,
summary: 'Open positions and what they still need.',
shapes: ['list', 'table', 'stats', 'card'],
options: ['limit'],
},
{
id: 'workforce.training',
label: 'Workforce training',
context: null,
summary: 'Training paths and progress against them.',
shapes: ['progress', 'list', 'stats', 'table', 'card'],
options: ['limit'],
},
{
/* The vetting weights a position is being specified with. `positionId`
context, but the "position" on Create Position is the draft in the form
rather than a saved record — which is the point: the resolver reads the
same field either way, so one source serves the form and the position it
becomes. */
id: 'position.vetting',
label: 'Position vetting weights',
context: 'positionId',
summary: 'How this position weights each screening criterion.',
shapes: ['weights', 'flow', 'progress', 'stats', 'table', 'card', 'insight'],
/**
* This reading can be written back.
*
* `writable` is what lets a definition declare `editable: true` and get
* controls instead of a read-out. It is a property of the *source*, not of
* the definition — a skill cannot make a reading writable by asking, and a
* source with no page willing to accept the write simply renders read-only.
* That keeps the closed vocabulary closed in both directions.
*/
writable: true,
writeSummary: 'Sets the screening weights on the position being specified.',
},
{
id: 'hires.recent',
label: 'Recent hires',
context: null,
summary: 'Who was hired, for which role, and when.',
shapes: ['list', 'table', 'timeline', 'stats', 'card'],
options: ['limit'],
},
{
id: 'hires.performance',
label: 'Hiring performance',
context: null,
summary: 'Hires, time-to-hire, quality and conversion, counted together.',
shapes: ['stats', 'card', 'table', 'flow', 'insight'],
},
{
/**
* Open roles that are not going to fill on their own.
*
* State rather than events, so it takes no `periods`: "which roles are at
* risk" is a question about now, and windowing it to last week would report
* the risks of last week.
*/
id: 'positions.risk',
label: 'Staffing risk',
context: null,
summary: 'Open roles with no applicants, no viable candidate, or nobody screened.',
shapes: ['list', 'table', 'stats', 'insight', 'card'],
options: ['limit'],
},
{
/**
* How good the applicant pool is, and how much of it has been looked at.
*
* Windowable, because applications are dated and "how has candidate quality
* moved this month" is a real question.
*/
id: 'candidates.quality',
label: 'Candidate quality',
context: null,
summary: 'Score bands, how much of the pool is screened, and interview coverage.',
shapes: ['stats', 'progress', 'table', 'list', 'flow', 'insight', 'card'],
options: ['periods', 'limit'],
},
{
/**
* The talent already known to this workspace — supply, not applicants.
*
* Someone in the pool has not applied to anything by being here, which is
* why this is a separate source from `candidates.quality` rather than a
* filter on it.
*/
id: 'talent.pool',
label: 'Talent pool',
context: null,
summary: 'Who is in the pool, how they score, and how complete their profiles are.',
shapes: ['stats', 'progress', 'table', 'list', 'insight', 'card'],
options: ['limit'],
},
{
/**
* Open roles against the people actually hired into them.
*
* Reads `demandFor`, which is the same reading the Positions page uses —
* including its refusal to invent a headcount for a position that never
* declared one.
*/
id: 'workforce.coverage',
label: 'Workforce coverage',
context: null,
summary: 'Open roles, and who has been hired into them.',
shapes: ['stats', 'progress', 'table', 'list', 'insight', 'card'],
options: ['limit'],
},
{
/**
* Activity that departs from this workspace's own pattern.
*
* Not windowable: a pattern is computed over the whole log, and a signal
* detected inside a seven-day window would be a different measurement
* wearing the same name.
*/
id: 'activity.signals',
label: 'Activity signals',
context: null,
summary: 'Concentration, bursts, off-hours work and privileged actions.',
shapes: ['insight', 'list', 'table', 'stats', 'card'],
options: ['limit'],
},
{
/** What happened, counted by kind and by who did it. */
id: 'activity.breakdown',
label: 'Activity breakdown',
context: null,
summary: 'Events by type and by account.',
shapes: ['stats', 'table', 'list', 'progress', 'flow', 'card'],
options: ['periods', 'limit'],
},
{
/**
* What is going wrong operationally, across domains.
*
* Deliberately cross-domain — a decision owed to a strong candidate, an
* unscreened backlog and a no-show rate are the same kind of problem to the
* person on shift, however differently they are stored.
*/
id: 'operations.risk',
label: 'Operational risk',
context: null,
summary: 'Decisions owed, unscreened backlog, and shifts going unworked.',
shapes: ['list', 'table', 'stats', 'insight', 'card'],
options: ['limit'],
},
{
/** The whole workspace in one row of figures. */
id: 'workspace.summary',
label: 'Workspace summary',
context: null,
summary: 'Positions, candidates, hires, talent and attendance, counted together.',
shapes: ['stats', 'card', 'table', 'list', 'insight'],
},
{
/**
* Shifts worked, missed and overrun.
*
* A workspace-level reading, so it needs no record in context and answers
* on any page that carries it. `periods` windows it the same way every
* other dated collection is windowed — on `created_date`, which for a
* shift is the instant it was worked.
*/
id: 'workforce.attendance',
label: 'Workforce attendance',
context: null,
summary: 'Shifts worked, late arrivals, absences and no-shows.',
shapes: ['stats', 'progress', 'table', 'list', 'flow', 'timeline', 'insight', 'card'],
options: ['periods', 'limit'],
},
{
/**
* Scheduled hours against hours actually worked.
*
* Separate from attendance because they answer different questions and one
* hides the other: a team can have perfect attendance and be running on
* thirty hours of overtime a week, and a single "workforce hours" source
* would report that as healthy.
*/
id: 'workforce.overtime',
label: 'Workforce overtime',
context: null,
summary: 'Scheduled against actual hours, and the overtime that resulted.',
shapes: ['stats', 'progress', 'table', 'list', 'flow', 'insight', 'card'],
options: ['periods', 'limit'],
},
{
id: 'activity.events',
label: 'Workspace activity',
context: null,
summary: 'What has happened across the workspace, most recent first.',
shapes: ['timeline', 'list', 'table', 'stats', 'card'],
options: ['limit'],
},
];
export const SUPPORTED_DATA_SOURCES = DATA_SOURCES.map((s) => s.id);
export const dataSourceFor = (id) => DATA_SOURCES.find((s) => s.id === id) || null;
/* ── Source / shape compatibility ───────────────────────────────────────────
One resolver, three consumers: `normalizeSection` refuses on it, the Board
editor's source picker filters on it, and the Owliver editor's per-capability
picker filters on it. They used to be three readings of `source.shapes` —
inline in the normalizer, absent from one editor and approximated in the
other — which is how a form could compose `list` against a source that has no
list in it and only find out at save. */
/**
* The section type a capability is drawn with, or null when it is prose.
*
* `summary` is the one capability with no component, so it is compatible with
* every source: the figures are read back as sentences rather than drawn.
*/
export const shapeForCapability = (capability) => owliverCapabilityFor(capability)?.shape || null;
/** Can this source be drawn as this shape? A null shape is prose, and always can. */
export function sourceSupportsShape(sourceId, shape) {
const source = dataSourceFor(sourceId);
if (!source) return false;
if (!shape) return true;
return Boolean(source.shapes?.includes(shape));
}
/**
* The sources that can fill this shape, optionally narrowed to what a placement
* can supply context for.
*
* `context` is the list a placement provides — `contextSuppliedBy(pages,
* placement)`. Passing it is how the Board editor offers only sources that can
* actually resolve where the section is mounted; the Owliver editor passes
* nothing, because a response with an unmet need asks which record is meant
* rather than rendering dead.
*/
export function sourcesForShape(shape, { context = null } = {}) {
return DATA_SOURCES.filter((source) => {
if (!sourceSupportsShape(source.id, shape)) return false;
if (!context) return true;
return !source.context || context.includes(source.context);
});
}
/**
* Whether a source reads an option a form can offer — `periods` or `limit`.
*
* Declared per source rather than inferred from its shapes, because the two do
* not line up: `positions.demand` is a list that honours `limit` and has no
* periods at all, while `candidates.activity` is the reverse. The editors used
* to guess from the shape list and offered period checkboxes that changed
* nothing.
*/
export const sourceSupportsOption = (sourceId, option) =>
Boolean(dataSourceFor(sourceId)?.options?.includes(option));
/**
* Can this reading be written back?
*
* The one question `editable:` is checked against. A page opts in by publishing
* a handler for the source (see `usePublishPageActions`); a definition opts in
* by declaring `editable: true`. Both have to hold before a control is drawn,
* so neither the author nor the page can enable editing on its own.
*/
export const isSourceWritable = (id) => Boolean(dataSourceFor(id)?.writable);
/**
* The periods a time-based section may ask for.
*
* Only the ones the data layer can actually compute from record timestamps.
* A period is a window over `created_date`, resolved at read time against the
* current date — never a stored figure and never a hard-coded date.
*/
export const PERIODS = [
{ id: 'today', label: 'Today' },
{ id: 'yesterday', label: 'Yesterday' },
{ id: 'last-7-days', label: 'Last 7 days' },
{ id: 'last-week', label: 'Last week' },
{ id: 'this-month', label: 'This month' },
{ id: 'previous-month', label: 'Previous month' },
];
export const SUPPORTED_PERIODS = PERIODS.map((p) => p.id);
export const periodLabel = (id) => PERIODS.find((p) => p.id === id)?.label || id;
/* ── Human labels ───────────────────────────────────────────────────────────
The vocabulary is written in kebab-case because it is configuration; it is
read by people, so every id has a label. One place, so the preview, the
rendered section and any future surface all say the same words. */
/** `flow` → `Flow`. */
export const sectionTypeLabel = (id) =>
SECTION_TYPES.find((t) => t.id === id)?.label || id;
/** `position.activity` → `Position activity`. */
export const dataSourceLabel = (id) => dataSourceFor(id)?.label || id;
/** `after-position-summary` → `After position summary`. */
export const placementLabel = (id) => {
const words = String(id || '').replace(/-/g, ' ').trim();
return words ? words[0].toUpperCase() + words.slice(1) : id;
};

View File

@@ -1,177 +0,0 @@
import { skillsForContext } from './registry';
import { ACTION_NAMES } from './actions';
/**
* Tools, described.
*
* Boundary 6. This adds **no capability**: every tool here is an action
* `actions.js` already performs, and `runAction` remains the only thing that
* performs them. What was missing was a description — what a tool does, what it
* needs, whether it changes anything, and whether a person should be asked
* first. Without that, a caller deciding whether to confirm an action had to
* hard-code a list of which ones were dangerous.
*
* Describing them separately is also what makes them exposable later. A future
* MCP surface publishes these descriptors and calls the same `runAction`;
* nothing in the business logic moves. That is the whole reason this file is a
* table rather than a set of wrappers.
*
* **The page boundary is inherited, not restated.** `toolsForContext` reads the
* skills that are reachable on the current page for the current agent, and
* collects what *they* declare. A tool is therefore reachable only when a skill
* on this page declares it and the agent carries that skill — so a tool can
* never reach data the page was not already offering, and selecting a different
* agent can only ever remove tools from that list.
*/
/**
* What each action is, in the terms a person confirming it would need.
*
* `requiresApproval` is a property of the action, never of the caller: an
* action that writes a record needs a person to agree whichever surface asked
* for it. `readOnly` actions move the reader somewhere and change nothing.
*/
export const TOOLS = [
{
name: 'create_position',
label: 'Create position',
summary: 'Writes a new job posting from a draft collected in conversation.',
params: ['draft', 'status'],
readOnly: false,
mutates: 'JobPosting',
/* Writes a record other people will act on. Always confirmed. */
requiresApproval: true,
},
{
name: 'open_create_skill_training',
label: 'Open Add Skill Training',
summary: 'Opens the Forge authoring form, prefilled from the conversation.',
params: ['prefill'],
readOnly: true,
mutates: null,
/* Opens a form. Nothing is written until the person submits it, so asking
twice would be asking about the same decision twice. */
requiresApproval: false,
},
{
name: 'open_create_training',
label: 'Open Add Training',
summary: 'Opens the training authoring form for a course.',
params: ['prefill', 'courseId'],
readOnly: true,
mutates: null,
requiresApproval: false,
},
{
name: 'navigate_to_positions',
label: 'Open Positions',
summary: 'Takes the reader to the Positions page.',
params: [],
readOnly: true,
mutates: null,
requiresApproval: false,
},
{
name: 'navigate_to_candidates',
label: 'Open Candidates',
summary: 'Takes the reader to the Candidates page.',
params: [],
readOnly: true,
mutates: null,
requiresApproval: false,
},
{
name: 'navigate_to_forge',
label: 'Open KROW Forge',
summary: 'Takes the reader to the Forge library.',
params: [],
readOnly: true,
mutates: null,
requiresApproval: false,
},
{
name: 'navigate_to_analytics',
label: 'Open Analytics',
summary: 'Takes the reader to the Analytics page.',
params: [],
readOnly: true,
mutates: null,
requiresApproval: false,
},
{
/**
* Open whichever Krow page a reading belongs to.
*
* The general form of the four fixed `navigate_to_*` actions above, which
* each name one destination. This one takes a page key and resolves it
* through the same placement table, so a skill can send the reader to the
* page its analysis was about without a new action per destination.
*
* Still bounded: `routeForPageKey` only knows addresses the product has,
* and an unknown key resolves to nothing rather than to a guess.
*/
name: 'open_related_page',
label: 'Open the related page',
summary: 'Takes the reader to the Krow page a reading came from.',
params: ['page'],
readOnly: true,
mutates: null,
requiresApproval: false,
},
];
const BY_NAME = new Map(TOOLS.map((tool) => [tool.name, tool]));
/** One tool's description, or null. */
export const describeTool = (name) => BY_NAME.get(name) || null;
export const TOOL_NAMES = TOOLS.map((tool) => tool.name);
/** Whether an action changes something a person should agree to first. */
export const toolRequiresApproval = (name) => Boolean(BY_NAME.get(name)?.requiresApproval);
/**
* Every action name a handler exists for but nothing describes.
*
* A handler with no descriptor is invisible to anything reasoning about tools —
* including whatever decides to ask for confirmation — so it would run
* unannounced. Asserted in the checks rather than left to review.
*/
export const undescribedActions = () =>
ACTION_NAMES.filter((name) => !BY_NAME.has(name));
/**
* The tools reachable on this page, for this agent.
*
* Derived from the scoped skill list, so the page boundary is inherited rather
* than re-implemented: a skill the page does not carry contributes no tools, and
* a skill the agent does not carry has already been removed from that list by
* `agentScopedDisabled`.
*
* `disabled` is expected to already carry the agent's scoping. Passing the raw
* account list yields the page's full tool set, which is what an unscoped
* caller should get.
*/
export function toolsForContext(contextId, disabled = [], customSkills = []) {
const reachable = skillsForContext(contextId, disabled, customSkills);
const names = new Set();
for (const skill of reachable) {
for (const action of skill.actions || []) names.add(action);
}
return [...names]
.map((name) => describeTool(name))
.filter(Boolean)
.sort((a, b) => a.label.localeCompare(b.label));
}
/**
* Whether this tool may run here.
*
* The check a caller makes before offering a control. Deliberately takes the
* resolved list rather than recomputing it, so a caller cannot accidentally ask
* the question against a wider scope than the one it rendered from.
*/
export const toolAllowed = (name, allowed = []) =>
allowed.some((tool) => tool.name === name);

View File

@@ -1,397 +0,0 @@
import {
SUPPORTED_DATA_SOURCES, SUPPORTED_PERIODS, SUPPORTED_SECTION_TYPES,
SUPPORTED_SKILL_PAGES, canonicalPage, dataSourceFor, isSourceWritable, placementFor,
sourceSupportsShape, surfaceFor,
} from './surfaces';
/**
* A name, as an id.
*
* Exported because two places need the same rule and were carrying their own:
* a section falling back to its title, and a definition falling back to its
* name when it declares no `id:`. Two slug functions that agree today is a
* definition whose id changes the day they stop agreeing.
*/
export const slugify = (value) => String(value || '')
.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
/**
* The `ui:` block of a skill definition, checked and normalized.
*
* A definition declares what it wants; this decides whether the product can
* honour it, and turns a loose YAML shape into one predictable record. Two
* things follow from doing it here rather than in the renderer:
*
* - **Nothing unknown reaches a component.** Every page, placement, type,
* data source and period is checked against the closed vocabulary in
* `surfaces.js`. An unrecognised value is an error with a message naming
* it, not a silently dropped key and not a rendered blank.
* - **Only listed keys survive.** The normalized section carries exactly the
* fields the renderers read. Anything else an author writes is ignored
* rather than passed through, so no property can arrive at React that this
* module did not put there.
*/
/**
* A section as the renderers receive it. Nothing else is carried.
*
* Used by both consumers of a definition. The page passes a `page`, so the
* section is checked against that surface's placements; Owliver passes
* `placement: false`, because a chat reply has no placement to sit at — the
* rest of the checks, and the record that comes out, are identical. That is
* deliberate: it is what makes a flow drawn in the panel the same section as
* the flow drawn on the page rather than a parallel shape that resembles it.
*
* `types` narrows what `type:` may be. The page offers the components it can
* mount; Owliver offers those plus the shapes that are only answers.
*/
export function normalizeSection(raw, {
page, errors, seen, fallbackId = '', where: label = null,
placement: wantPlacement = true, types = SUPPORTED_SECTION_TYPES,
shapeFor = (type) => type,
}) {
const where = label || `ui.${page}`;
/* Where an error points. A page section is addressed by the id it was given;
a capability response is already addressed by the capability it answers, so
appending a generated section id there would name something the author
never wrote. */
const at = label ? where : null;
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
errors.push(`${where}: each section must be a mapping of options.`);
return null;
}
/* An id is how a section is keyed and de-duplicated, not something an author
should have to invent for a definition that declares exactly one. Falls
back to the title, then to the skill's own id. */
const id = slugify(raw.id) || slugify(raw.title) || slugify(fallbackId);
if (!id) {
errors.push(`${where}: a section needs an \`id\`.`);
return null;
}
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
errors.push(`${at || `${where}.${id}`}: \`id\` must be lower-case letters, numbers and dashes.`);
return null;
}
if (seen.has(id)) {
errors.push(`${where}: two sections share the id \`${id}\`.`);
return null;
}
seen.add(id);
/**
* The shape this section is drawn with.
*
* Inferred from the source when it is not written, the same way a placement
* left out is filled in from the surface. A source declares the shapes it can
* fill, in the order they suit it, so the first is the reading its author
* would have chosen — and a definition naming a page, a placement and a
* source has already said everything that needs saying. Refusing it for the
* one field it can derive would be the format asking for ceremony.
*/
const declaredSource = String(raw.data?.source ?? raw.source ?? '').trim();
const type = String(raw.type || '').trim()
|| dataSourceFor(declaredSource)?.shapes?.find((shape) => types.includes(shape))
|| '';
if (!type) {
errors.push(`${at || `${where}.${id}`}: a section needs a \`type\`.`);
return null;
}
if (!types.includes(type)) {
errors.push(
`Unsupported skill component: ${type}. Supported types: ${types.join(', ')}.`
);
return null;
}
/* `position:` is what the format calls it; `placement:` is accepted because
it is the word the rest of the system uses. A section that is an answer
rather than a panel has nowhere to be placed, and says so with `null`. */
let placement = null;
if (wantPlacement) {
const surface = surfaceFor(page);
const declaredPlacement = String(raw.position || raw.placement || '').trim();
/* `placementFor` reads the surface's own list and the alias table, so
`grid-card` and `after-position-card` are one placement written two ways
and neither resolves to something the surface does not offer. */
placement = declaredPlacement
? placementFor(page, declaredPlacement)
: surface.placements[0];
if (!placement) {
errors.push(
`Unsupported placement: ${declaredPlacement} on ${page}. `
+ `Supported placements: ${surface.placements.join(', ')}.`
);
return null;
}
}
/* `data.source:` and a flat `source:` mean the same thing. The nested form
groups options when a section grows; the flat form is what a one-section
definition actually reads like, and refusing it would be the format being
precious about punctuation. */
const source = declaredSource;
if (!source) {
errors.push(`${at || `${where}.${id}`}: a section needs \`data.source\`.`);
return null;
}
if (!SUPPORTED_DATA_SOURCES.includes(source)) {
errors.push(
`Unsupported data source: ${source}. Supported sources: ${SUPPORTED_DATA_SOURCES.join(', ')}.`
);
return null;
}
/* A source knows which shapes it can fill. Asking for a timeline of something
that has no dates is an authoring mistake worth naming now rather than
rendering as an empty panel later. */
const definition = dataSourceFor(source);
/* `shapeFor` is how a type that is not a drawn component is exempted: a
summary is prose, so there is no shape for a source to be incompatible
with. Every drawn type maps to itself. */
const shape = shapeFor(type);
/* `sourceSupportsShape` rather than a reading of `definition.shapes` here:
the editors' source pickers ask the same question, and the answer has to
come from one place or a form can compose what the normalizer refuses. */
if (!sourceSupportsShape(source, shape)) {
errors.push(
`${at || `${where}.${id}`}: \`${source}\` cannot be shown as \`${type}\`. It supports: ${definition.shapes.join(', ')}.`
);
return null;
}
const periods = Array.isArray(raw.periods) ? raw.periods.map((p) => String(p).trim()) : [];
const unknownPeriod = periods.find((p) => !SUPPORTED_PERIODS.includes(p));
if (unknownPeriod) {
errors.push(
`Unsupported period: ${unknownPeriod}. Supported periods: ${SUPPORTED_PERIODS.join(', ')}.`
);
return null;
}
const limit = Number(raw.limit);
/**
* Whether this section offers controls rather than a read-out.
*
* Two independent things must agree before anything is editable, and this is
* the first: the definition asking for it, and the source declaring that it
* can be written at all. The second is the page publishing a handler for that
* source at render time. A definition asking to edit a reading the product
* does not expose for writing is an authoring mistake worth naming here,
* rather than a control that silently does nothing.
*/
const editable = raw.editable === true || raw.editable === 'true';
if (editable && !isSourceWritable(source)) {
errors.push(
`${at || `${where}.${id}`}: \`${source}\` cannot be edited. It is a reading, not a setting.`
);
return null;
}
return {
id,
title: String(raw.title || '').trim() || null,
description: String(raw.description || '').trim() || null,
type,
placement,
source,
/* Declared intent only. A section stays read-only wherever no page offers
to accept the write — see `SkillSurface` and `SkillSectionBlock`. */
editable,
/* Context the page must supply for this source to resolve. */
context: definition.context,
periods,
limit: Number.isFinite(limit) && limit > 0 ? Math.min(50, Math.round(limit)) : null,
};
}
/**
* The keys that mean "this object *is* a section".
*
* How the two shapes are told apart. A definition may write its UI either way:
*
* ui: ui:
* type: flow positions:
* placement: … sections:
* source: … - type: flow
*
* The first is one section applying to every page the skill declares; the
* second addresses pages by name and can differ per page. Both are legitimate,
* and the difference is structural — an object carrying `type` or `source` is a
* section, an object whose keys are page names is a page map. Nothing is
* decided by a skill id, and neither shape is privileged.
*
* There is a third, and it is the one people actually write:
*
* ui:
* - page: Positions
* placement: grid-card
* source: position.activity
* - page: Analytics
* placement: panel
* source: hires.performance
*
* A list of sections, each naming its own page. It reads the way the thing
* reads — "this skill puts this here, and that there" — and it was the one
* shape the parser refused, with a single error that took the whole block down
* and left the definition declaring no pages at all. Several entries may name
* the same page; they become several sections on it, in the order written.
*/
const SECTION_KEYS = new Set([
'id', 'type', 'title', 'description', 'placement', 'position', 'data', 'source', 'periods',
'limit', 'editable',
]);
const looksLikeSection = (value) =>
Boolean(value)
&& typeof value === 'object'
&& !Array.isArray(value)
&& Object.keys(value).some((key) => SECTION_KEYS.has(key));
/** The sections a page entry declares, in either the list or single-section form. */
function sectionsOf(config) {
if (Array.isArray(config?.sections)) return config.sections;
if (Array.isArray(config)) return config;
if (looksLikeSection(config)) return [config];
return null;
}
/**
* The whole `ui:` block, normalized per page.
*
* Returns `{ ui, errors }`. `ui` holds only what validated, so a definition with
* one bad section still registers its good ones — and the author still sees why
* the other was refused.
*/
export function normalizeSkillUi(rawUi, { declaredPages = [], skillId = '' } = {}) {
const errors = [];
const ui = {};
if (rawUi == null) return { ui, errors };
if (typeof rawUi !== 'object') {
return {
ui,
errors: ['`ui` must be a section, a list of sections, or a mapping of page names to sections.'],
};
}
const pages = declaredPages.map(canonicalPage).filter(Boolean);
/**
* A list, where every entry names the page it belongs to.
*
* Grouped rather than keyed, so two entries naming one page are two sections
* on it rather than the second quietly replacing the first — which is what a
* page map would have done. `seen` is shared across the whole list because
* section ids are unique per definition, not per page.
*/
if (Array.isArray(rawUi)) {
const seen = new Set();
rawUi.forEach((entry, index) => {
const where = `ui[${index}]`;
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
errors.push(`${where}: each entry must be a mapping of options.`);
return;
}
const rawPage = entry.page ?? entry.pages ?? entry.surface;
if (rawPage == null || Array.isArray(rawPage)) {
errors.push(`${where}: an entry needs a \`page\`.`);
return;
}
const page = canonicalPage(rawPage);
if (!page) {
errors.push(
`Unsupported page: ${rawPage}. Supported pages: ${SUPPORTED_SKILL_PAGES.join(', ')}.`
);
return;
}
/* Declared `pages:` still governs reach when it is written: a definition
may not render onto a surface it did not say it applied to. When it is
not written, the list is the declaration — see `parseSkill`. */
if (pages.length && !pages.includes(page)) {
errors.push(`\`${where}\` names \`${rawPage}\`, which is not listed under \`pages\`.`);
return;
}
const section = normalizeSection(entry, {
page, errors, seen, fallbackId: `${skillId}-${index + 1}`, where,
});
if (!section) return;
if (!ui[page]) ui[page] = { sections: [] };
ui[page].sections.push(section);
});
return { ui, errors };
}
/* Shorthand: one section, applied to every page the skill declares. The keys
inside it are section options and are never read as page names — which is
exactly what this branch exists to prevent. */
if (looksLikeSection(rawUi)) {
if (!pages.length) {
return { ui, errors: ['`ui` is configured but the skill declares no `pages`.'] };
}
for (const page of pages) {
const section = normalizeSection(rawUi, {
page, errors, seen: new Set(), fallbackId: skillId,
});
if (section) ui[page] = { sections: [section] };
}
return { ui, errors };
}
/* Otherwise every key is a page name. */
for (const [rawPage, config] of Object.entries(rawUi)) {
const page = canonicalPage(rawPage);
if (!page) {
errors.push(
`Unsupported page: ${rawPage}. Supported pages: ${SUPPORTED_SKILL_PAGES.join(', ')}.`
);
continue;
}
/* A page cannot be extended unless the skill also declares it. Otherwise a
definition could render onto a surface it never said it applied to. */
if (pages.length && !pages.includes(page)) {
errors.push(`\`ui.${rawPage}\` is configured but \`${rawPage}\` is not listed under \`pages\`.`);
continue;
}
const rawSections = sectionsOf(config);
if (!rawSections) {
errors.push(`ui.${rawPage}: expected a \`sections\` list, or a single section.`);
continue;
}
const seen = new Set();
const sections = rawSections
.map((section) => normalizeSection(section, {
page: rawPage, errors, seen, fallbackId: skillId,
}))
.filter(Boolean);
if (sections.length) ui[page] = { sections };
}
return { ui, errors };
}
/** Every section this skill contributes to a page, in declaration order. */
export const sectionsForPage = (skill, page) => {
const key = canonicalPage(page);
return key ? skill?.ui?.[key]?.sections || [] : [];
};
/** How many UI sections a definition registers, across every page. */
export const countSections = (skill) =>
Object.values(skill?.ui || {}).reduce((n, page) => n + (page.sections?.length || 0), 0);

View File

@@ -1,84 +0,0 @@
import { useMemo } from 'react';
import { usePreferences } from '@/lib/krowHooks';
import {
isSkillOnPage, pageDefinitionForSkillId, skillStatesForPage, workforceSkillStates,
workforceSkillsForPage,
} from './pageSkills';
/**
* A page's skill attachments, resolved once.
*
* Every consuming page needs the same two things before it can ask anything
* else: the account's custom definitions (a skill added in Settings is as real
* as one on disk) and the ids it has switched off. Reading those in one hook
* means no page can forget either, and none has to know where they are stored.
*
* The hook is deliberately thin — it resolves attachment and hands back the
* helpers already bound to this page. The rules themselves stay in
* `pageSkills.js`, which is what keeps them testable without React.
*
* Usage is always the same shape:
*
* const { skills, statesFor } = usePageSkills('profile');
* if (!skills.length) return null; // nothing attached: no section
*
* `attaches(skillId)` is the gate for a contextual addition to something that
* already exists — a training prompt beside a position requirement, say — where
* the page is not listing skills but is about to mention one.
*/
/**
* Every training path, for the views that manage training rather than surface it.
*
* The same preferences plumbing as `usePageSkills`, asking `pageSkills.js` the
* other question it answers — so Workspace & Skills and Skill Development count
* paths from the registry rather than keeping a list of their own.
*/
export function useWorkforcePaths() {
const preferences = usePreferences();
const customKey = JSON.stringify(preferences.customSkills || []);
const disabledKey = JSON.stringify(preferences.disabledSkills || []);
return useMemo(() => {
const options = {
customSources: JSON.parse(customKey),
disabled: JSON.parse(disabledKey),
};
return {
/** The training paths themselves, before anyone's progress is applied. */
paths: workforceSkillStates([], null, options).map((entry) => entry.definition),
/** Each path paired with one person's state in it. */
statesFor: (courses, profile) => workforceSkillStates(courses, profile, options),
};
}, [customKey, disabledKey]);
}
export function usePageSkills(pageId) {
const preferences = usePreferences();
/* `usePreferences` rebuilds its object on every read, so the arrays inside it
are new identities each render. Keying the memo on their content is what
stops every consumer from recomputing — and re-rendering — continuously. */
const customKey = JSON.stringify(preferences.customSkills || []);
const disabledKey = JSON.stringify(preferences.disabledSkills || []);
return useMemo(() => {
const customSources = JSON.parse(customKey);
const disabled = JSON.parse(disabledKey);
const options = { customSources, disabled };
const skills = workforceSkillsForPage(pageId, options);
return {
/** The definitions attached here. Empty means render nothing. */
skills,
/** True when this page may surface that capability at all. */
attaches: (skillId) => isSkillOnPage(skillId, pageId, options),
/** The definition behind a capability, but only if attached here. */
definitionFor: (skillId) => pageDefinitionForSkillId(skillId, pageId, options),
/** Each attached definition paired with one person's state in it. */
statesFor: (courses, profile) => skillStatesForPage(pageId, courses, profile, options),
/** Passed through to components that resolve attachment themselves. */
customSources,
};
}, [pageId, customKey, disabledKey]);
}

View File

@@ -1,924 +0,0 @@
import { doc, heading, insights, list, note, table, text } from '@/components/ai-assistant/blocks';
import {
demandFor, poolFor, prepareAssignment, prioritise, startLabel, workforceStatusFor,
} from '@/lib/workforce';
import { levelLabel, skillName } from '@/lib/skillGraph';
/**
* Owliver's workforce layer: the conversation, not the reasoning.
*
* Every number, ranking, availability check and eligibility decision below comes
* from `lib/workforce.js`. This module's entire job is to work out which
* question was asked, which position it is about, and how to say the engine's
* answer — plus the one thing a chat can do that a page cannot, which is to
* propose an action and wait to be told to take it.
*
* The rule that shapes the file: **nothing here writes, and nothing here
* decides.** A preview is built by calling `prepareAssignment`, and executing it
* is a separate turn that re-derives the plan from live data before the existing
* mutation runs. That re-derivation is deliberate — between seeing a preview and
* confirming it, somebody else may have taken the person.
*/
/* ── Which question is this? ───────────────────────────────────────────── */
const has = (q, ...terms) => terms.some((t) => q.includes(t));
/**
* The workforce intent behind a question, or null to let the page's own
* responder answer it.
*
* Ordered by specificity: confirmation before proposal, proposal before
* enquiry. "Assign them" after a preview must not be read as a fresh request
* for recommendations.
*/
export function matchWorkforceIntent(question) {
const q = String(question).toLowerCase();
/* Inspection and record-opening are checked before assignment, so "show X"
and "open the full profile for X" never read as a request to assign. */
if (has(q, 'open the full profile', 'view full profile', 'full profile for')) return 'open_profile';
if (has(q, 'show ', "'s details", 'candidate details', 'tell me about ')) return 'candidate_detail';
if (has(q, 'can ', 'why not eligible', 'be assigned')) return 'eligibility';
if (has(q, 'confirm interview')) return 'confirm_interview';
if (has(q, 'set up an interview', 'set up interview', 'schedule an interview', 'schedule interview',
'interview for ', 'set up their interview')) return 'interview_setup';
/* Answering "how many people do you need?" — checked before assignment,
because the reply that carries the number also carries the position title
and would otherwise read as a fresh request to assign against it. */
if (has(q, 'set headcount', 'set the headcount', 'headcount for', 'headcount to',
'headcount of')) return 'set_headcount';
if (has(q, 'confirm assignment', 'yes, assign', 'confirm the assignment')) return 'confirm_assign';
if (has(q, 'assign ', 'fill this position', 'fill the position', 'assign the best', 'assign them')) return 'assign';
if (has(q, 'ready for interview', 'who should i interview', 'interview ready')) return 'interview_ready';
if (has(q, 'applied today', "today's applicant", 'new applicant', 'new application')) return 'applied_today';
if (has(q, 'available', 'start earliest', 'can start', 'availability', 'free now')) return 'availability';
if (has(q, 'who matches', 'who can fill', 'find candidates', 'best match', 'strongest candidate',
'recommend candidate', 'suitable candidate', 'who is suitable', 'candidates for')) return 'matches';
if (has(q, 'needs people first', 'biggest gap', 'largest gap', 'most understaffed',
'which position should i fill', 'priority')) return 'priority';
return null;
}
/**
* The headcount a sentence states, or null.
*
* Deliberately narrow. A bare integer anywhere in the question would also match
* the digits in a position title, a pay band or a date, and setting demand from
* a misread number is not a recoverable mistake — the whole point of asking is
* that nobody wants a figure nobody chose. So the number has to be attached to
* a phrase that means "this many people".
*/
export function headcountFrom(question) {
const q = String(question).toLowerCase();
const match = /\bto\s+(\d{1,3})\b/.exec(q)
|| /\bheadcount\s+(?:of\s+|to\s+)?(\d{1,3})\b/.exec(q)
|| /\b(\d{1,3})\s+(?:people|person|staff|workers?)\b/.exec(q);
if (!match) return null;
const count = Number(match[1]);
return Number.isFinite(count) && count >= 1 && count <= 999 ? count : null;
}
/* ── Which position? ───────────────────────────────────────────────────── */
/**
* The position a question is about.
*
* Named titles win, longest match first so "Event Server – Fine Dining" is not
* resolved as "Event Server". Failing that, the position the reader currently
* has open. Failing that, nothing — and the caller asks rather than guessing,
* because assigning somebody to the wrong role is not a recoverable mistake.
*/
export function resolvePosition(question, positions = [], currentId = null) {
const q = String(question).toLowerCase();
let best = null;
for (const position of positions) {
const title = String(position.title || '').toLowerCase();
/* Titles use an en dash; people type a hyphen. */
const loose = title.replace(/[–—]/g, '-');
const asked = q.replace(/[–—]/g, '-');
if (title && (q.includes(title) || asked.includes(loose))
&& (!best || title.length > String(best.title).length)) {
best = position;
}
}
if (best) return best;
return positions.find((p) => p.id === currentId) || null;
}
/** The question Owliver asks when it cannot tell which role is meant. */
export const askWhichPosition = (positions = []) => ({
doc: doc(
text('Which position should I work on?'),
list(positions.filter((p) => p.status === 'active').slice(0, 6).map((p) => p.title)),
note('Name the role and I will read its requirements, demand and the people available for it.')
),
followUp: positions
.filter((p) => p.status === 'active')
.slice(0, 4)
.map((p) => ({ label: p.title, prompt: `Who matches ${p.title}?` })),
});
/**
* The person a question names, matched against the pool the engine scored.
*
* Full name first, then a unique first name — "assign Maria" is how people
* actually speak, but only when exactly one Maria is in play. An ambiguous first
* name resolves to nothing and the caller asks, because assigning the wrong
* person is not a recoverable mistake either.
*/
export function resolveCandidate(question, pool = []) {
const q = String(question).toLowerCase();
const byFull = pool.filter((row) => row.name && q.includes(String(row.name).toLowerCase()));
if (byFull.length === 1) return { row: byFull[0] };
if (byFull.length > 1) {
/* Longest name wins: "Arun Kumar" over a hypothetical "Arun". */
return { row: [...byFull].sort((a, b) => b.name.length - a.name.length)[0] };
}
const byFirst = pool.filter((row) => {
const first = String(row.name || '').split(' ')[0].toLowerCase();
return first.length > 2 && new RegExp(`\\b${first}\\b`).test(q);
});
if (byFirst.length === 1) return { row: byFirst[0] };
if (byFirst.length > 1) return { ambiguous: byFirst };
return {};
}
/* ── Saying what the engine found ──────────────────────────────────────── */
/**
* Why this person fits, in the engine's own terms.
*
* Requirement lines come from the match; availability and duration come from the
* commitment check. A gap is stated as a gap — a candidate one level short is
* useful to see, and calling them a strong match would be the single most
* damaging thing this assistant could do.
*/
function reasonsFor(row) {
const out = [];
if (!row.match) {
return ['This position states no requirements this candidate can be scored against'];
}
for (const line of row.match.met) {
out.push(`✓ ${line.name} — ${line.heldLabel}`);
}
for (const line of row.match.gaps) {
out.push(`⚠ ${line.name} — ${line.heldLabel}, needs ${line.requiredLabel}`);
}
/* Availability is only known for people the workforce system holds a record
for. For everyone else it is reported as unknown rather than as free. */
if (row.availability.known === false) {
out.push('Availability not on file');
} else {
out.push(row.availability.available ? '✓ Available when this starts' : `✕ ${row.availability.reason}`);
}
if (row.duration.note) out.push(`⚠ ${row.duration.note}`);
return out;
}
/**
* The record to open for a recommended person.
*
* One id, decided in one place. `poolFor` resolved this row against the
* Candidates dataset before it was ever ranked, so `candidateId` is the id of
* an application that genuinely exists — never a name, a rank, or a record
* invented to make the link work. A row without one never reaches here, because
* a person with no candidate record is not returned as a match at all.
*
* The route is the existing candidate route, opening the existing full profile.
* The position travels with it so the profile knows what the person was being
* considered for.
*/
export function candidateRoute(row, applications = [], position = null) {
const id = row?.candidateId;
if (!id) return null;
return position
? `/admin/candidates/${id}?for=${encodeURIComponent(position.id)}`
: `/admin/candidates/${id}`;
}
/**
* One candidate, as a card.
*
* Clickable when there is a record behind the name. The reasons are the engine's
* — met requirements, gaps, and the availability verdict — so a card can never
* read as a stronger endorsement than the match actually is.
*/
const candidateBlock = (row, index, { position = null, applications = [], inspectable = true } = {}) => {
/**
* Two different things a reader wants from a match, kept apart.
*
* **View Profile** opens that person's record on the Candidates page. It is an
* explicit control rather than the whole card, so reading the match reasons
* cannot navigate away by accident — and it resolves for everyone, applied or
* not, because the profile page reads both kinds of record.
*
* **The card itself** still asks Owliver about this person for this role, which
* is the existing inspect-and-assign conversation, unchanged.
*
* The hint stays informational: it explains what opening the profile will and
* will not show, and is no longer the only way in.
*/
const to = candidateRoute(row, applications, position);
/**
* The headline says what kind of evidence the number rests on.
*
* A skill match is scored on training this person completed; a requirement
* match is scored on what their application states. Printing both as "match"
* would make the weaker claim borrow the authority of the stronger one, and a
* candidate with nothing to score says so rather than showing a 0.
*/
const headline = !row.scored
? 'not scored'
: row.basis === 'verified'
? `${row.score}% match`
: `${row.score}% requirement fit`;
return insights([{
tone: row.strong ? 'success' : row.scored ? 'info' : 'neutral',
title: `${index}. ${row.name} — ${headline}`,
body: reasonsFor(row).join(' · '),
prompt: !inspectable || !position ? null : `Show ${row.name} for ${position.title}`,
action: to ? { label: 'View Profile', to } : null,
/* Two informational states, never a control: what the score rests on, and
whether they have applied for this role. The profile opens either way. */
hint: [
row.basis === 'stated'
? 'Scored from their application — no verified training on file.'
: !row.scored ? 'Insufficient profile data to score against this role.' : null,
row.applied ? null : 'No application for this role yet — assigning them creates one.',
].filter(Boolean).join(' ') || null,
}]);
};
/**
* The people who could do this work, ranked.
*
* Shows the unavailable and the under-qualified too, marked as such: "nobody is
* free" is something an admin needs to see, and filtering it away leaves them
* asking the same question again tomorrow.
*/
export function candidateMatches(position, context) {
const pool = poolFor(position, context);
const status = workforceStatusFor(position, context);
if (!pool.length) {
return {
doc: doc(
text(`There are no candidates on file to score against **${position.title}**.`),
note('Candidates come from the Candidates page. Once somebody applies — to this role or any other — they can be scored against this position.')
),
};
}
/* Committed elsewhere is a workforce fact, so it can only exclude somebody the
workforce system holds a record for. A candidate with no such record is
ranked below the verified ones, never filtered out by an availability
nobody has recorded. */
const eligible = pool.filter((r) => r.availability.available);
const blocked = pool.filter((r) => !r.availability.available);
const shown = eligible.slice(0, 5);
const verified = pool.filter((r) => r.basis === 'verified').length;
return {
doc: doc(
heading(`Best matches for ${position.title}`,
`${pool.length} candidate${pool.length === 1 ? '' : 's'} · ${verified} with verified skills · ${status.strong.length} strong`),
...(shown.length
? shown.map((row, i) => candidateBlock(row, i + 1, { applications: context.applications, position }))
: [text('Nobody is currently free for this role.')]),
pool.length > shown.length + blocked.length
? note(`${pool.length - shown.length - blocked.length} more candidate${pool.length - shown.length - blocked.length === 1 ? '' : 's'} on file — ask for the full list or open Candidates to see them all.`)
: null,
blocked.length
? note(`${blocked.length} other ${blocked.length === 1 ? 'person is' : 'people are'} qualified but committed elsewhere — ask who is available next to see when they free up.`)
: null,
status.demand.declared
? text(`This position needs **${status.demand.remaining}** more of **${status.demand.required}**.`)
: null
),
followUp: shown.length
? [
{ label: 'Assign the best', prompt: `Assign the best candidates to ${position.title}` },
{ label: 'Who is available next?', prompt: `Who is available next for ${position.title}?` },
]
: [{ label: 'Who is available next?', prompt: `Who is available next for ${position.title}?` }],
};
}
/** Who is free, who is not, and when the committed ones come back. */
export function availability(position, context) {
const pool = poolFor(position, context);
if (!pool.length) {
return { doc: doc(text(`There are no candidates on file to assess for **${position.title}**.`)) };
}
return {
doc: doc(
heading(`Availability for ${position.title}`, `starts ${startLabel(position)}`),
table(
[
{ key: 'name', label: 'Person' },
{ key: 'state', label: 'Availability' },
{ key: 'match', label: 'Match', align: 'right' },
],
pool.slice(0, 8).map((row) => ({
name: row.name,
state: row.availability.available ? 'Available' : row.availability.reason,
match: `${row.score}%`,
}))
),
note('Availability is measured against this position’s start date — somebody finishing a role before it begins counts as free.')
),
};
}
/** Applications created today, across a position or the whole board. */
export function appliedToday(position, context, positions = []) {
const scope = position ? [position] : positions;
const rows = scope.flatMap((p) =>
workforceStatusFor(p, context).newToday.map((row) => ({
name: row.name,
position: p.title,
time: new Date(row.application.created_date).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }),
status: row.application.status,
match: row.match ? `${row.match.score}%` : '—',
}))
);
if (!rows.length) {
return {
doc: doc(text(position
? `No applications for **${position.title}** today.`
: 'No applications arrived today.')),
};
}
return {
doc: doc(
heading(`${rows.length} new ${rows.length === 1 ? 'application' : 'applications'} today`),
table(
[
{ key: 'name', label: 'Candidate' },
{ key: 'position', label: 'Position' },
{ key: 'time', label: 'Applied' },
{ key: 'status', label: 'Status' },
{ key: 'match', label: 'Match', align: 'right' },
],
rows
),
note('Match is shown only for people who carry a worker profile; an applicant without one cannot be scored.')
),
};
}
/** Candidates whose application has reached a stage where an interview is next. */
export function interviewReady(position, context, positions = []) {
const scope = position ? [position] : positions;
const rows = scope.flatMap((p) => {
const status = workforceStatusFor(p, context);
return status.applicants
.filter((a) => ['ai_screened', 'shortlisted'].includes(a.application.status))
.map((a) => ({
name: a.name,
position: p.title,
stage: a.application.status === 'ai_screened' ? 'Screened' : 'Shortlisted',
match: a.match ? `${a.match.score}%` : '—',
}));
});
if (!rows.length) {
return {
doc: doc(text(position
? `Nobody is waiting on an interview decision for **${position.title}**.`
: 'Nobody is currently screened or shortlisted and waiting on an interview.')),
};
}
return {
doc: doc(
heading(`${rows.length} ready for interview`),
table(
[
{ key: 'name', label: 'Candidate' },
{ key: 'position', label: 'Position' },
{ key: 'stage', label: 'Stage' },
{ key: 'match', label: 'Match', align: 'right' },
],
rows
),
note('Interviews are scheduled from the candidate’s record on the position page — I can tell you who is ready, but I do not create the booking.')
),
};
}
/** Which role gets people first, and the engine's stated reasons. */
export function positionPriority(positions, context) {
const ranked = prioritise(positions, context);
if (!ranked.length) {
return { doc: doc(text('No active position currently has an unfilled headcount.')) };
}
const top = ranked[0];
return {
doc: doc(
heading('Hiring priority', `${ranked.length} ${ranked.length === 1 ? 'position needs' : 'positions need'} people`),
insights(ranked.slice(0, 3).map((row) => ({
tone: row.band === 'High' ? 'warning' : 'info',
title: `${row.position.title} — ${row.band}`,
body: row.reasons.join(' · '),
}))),
text(`Start with **${top.position.title}**.`)
),
followUp: [{ label: `Who matches ${top.position.title}?`, prompt: `Who matches ${top.position.title}?` }],
};
}
/* ── The missing headcount ─────────────────────────────────────────────── */
/**
* What to say when a position never stated how many people it wants.
*
* This used to be the end of the conversation: a sentence explaining that there
* was no gap to fill, and no follow-up, on a request the reader had just been
* offered a chip for. The engine had in fact already picked somebody — the
* refusal was thrown away in front of a viable plan — but the count it would
* have been proposing against was the schema's fallback of 1, not a number the
* employer had given. Presenting that as their answer is the thing the demand
* model exists to avoid.
*
* So the missing value is asked for rather than assumed. What is offered is
* read off this position: one, two, and the number of people who are actually
* qualified and free for it, so the shortcut with the most information behind it
* is on the list. Nothing is written until one is chosen, and typing a different
* number works exactly as well as clicking a chip.
*/
export function askHeadcount(position, context) {
const demand = demandFor(position, context);
const status = workforceStatusFor(position, context);
const strong = status.strong.length;
const options = [...new Set([1, 2, strong])]
.filter((n) => n >= 1 && n <= 99)
.sort((a, b) => a - b);
const onIt = demand.assigned
? `${demand.assigned} ${demand.assigned === 1 ? 'person is' : 'people are'} already on it`
: 'Nobody is on it yet';
const free = strong
? `${strong} ${strong === 1 ? 'person is' : 'people are'} qualified and free when it starts`
: 'nobody is currently both qualified and free';
return {
doc: doc(
text(`How many people do you need for **${position.title}**?`),
note(`${onIt}, and ${free}. It has never stated a headcount, and I will not `
+ 'guess one — tell me the number and I will propose against it.')
),
followUp: options.map((count) => ({
label: `${count} ${count === 1 ? 'person' : 'people'}`,
prompt: `Set headcount for ${position.title} to ${count}`,
})),
};
}
/**
* The turn after the number is written: the proposal it was asked for.
*
* Composed from `assignmentPreview` rather than restating it, so the answer the
* reader gets here is the same answer the same question gives from then on.
*/
export function headcountSet(position, context) {
const count = Number(position.headcount);
const preview = assignmentPreview(position, context);
return {
doc: doc(
text(`**${position.title}** now asks for **${count}** ${count === 1 ? 'person' : 'people'}.`),
preview.doc.blocks
),
followUp: preview.followUp,
};
}
/** The write did not land. Nothing is claimed that did not happen. */
export function headcountFailed(position) {
return {
doc: doc(
text(`I could not set the headcount on **${position.title}**.`),
note('Nothing was changed. The position still states no demand.')
),
};
}
/* ── Proposing an assignment ───────────────────────────────────────────── */
/**
* The preview. Nothing is written here.
*
* `prepareAssignment` picks the people; this states who, why, and what the
* position looks like afterwards. The confirmation is a follow-up carrying the
* position title, so accepting it goes back through the same resolution path a
* typed sentence would — and re-checks the data before writing.
*/
export function assignmentPreview(position, context) {
const plan = prepareAssignment(position, context);
const demand = demandFor(position, context);
if (!demand.declared) return askHeadcount(position, context);
if (plan.blocked === 'full') {
return { doc: doc(text(`**${position.title}** is already fully staffed — ${demand.assigned} of ${demand.required}.`)) };
}
if (plan.blocked === 'no_available_matches') {
const pool = poolFor(position, context);
/**
* People the engine will not propose automatically, but would accept.
*
* `prepareAssignment` selects from `strong`, and `strong` requires
* *verified* evidence — a deliberate rule, and the right one: a stated
* requirement is a claim on an application, not proof somebody can do the
* work. What was wrong was the sentence it produced. On Bartender, three
* people meet every stated requirement, have no gaps and are free when it
* starts, and the reply said nobody was qualified or free. That is not a
* cautious answer, it is an untrue one — and it ended the conversation,
* because the branch offered no follow-up at all.
*
* So the rule stands and the reply changes: say which kind of evidence is
* missing, show who is actually there, and offer them by name. Naming one
* goes through `namedAssignmentPreview`, which already re-checks them and
* asks for confirmation before anything is written — the admin makes the
* judgement the verified/stated distinction exists to protect, instead of
* the distinction quietly making it for them.
*/
const ready = pool.filter(
(row) => row.availability.available && row.match && !row.match.gaps.length
);
if (ready.length) {
const shown = ready.slice(0, 3);
return {
doc: doc(
text(`Nobody with **verified** skills is free for **${position.title}**, so I will not `
+ 'propose an assignment on my own.'),
text(`${ready.length} ${ready.length === 1 ? 'person meets' : 'people meet'} every stated `
+ `requirement and ${ready.length === 1 ? 'is' : 'are'} free when it starts:`),
...shown.map((row, i) => candidateBlock(row, i + 1, {
applications: context.applications, position,
})),
note('Scored from their applications rather than verified training. Name one and I will '
+ 'check them again before anything is written.')
),
followUp: shown.map((row) => ({
label: `Assign ${row.name}`,
prompt: `Assign ${row.name} to ${position.title}`,
})),
};
}
const blocked = pool.filter((r) => !r.availability.available);
return {
doc: doc(
text(`I cannot propose anyone for **${position.title}** — nobody is both qualified and free when it starts.`),
blocked.length
? insights(blocked.slice(0, 3).map((row) => ({
tone: 'warning',
title: row.name,
body: `${row.match.score}% on skills, but ${row.availability.reason.toLowerCase()}`,
})))
: null,
note(`${demand.remaining} of ${demand.required} still unfilled.`)
),
/* Even with nobody to offer, the conversation has somewhere to go. */
followUp: [
{ label: 'Who is available next?', prompt: `Who is available next for ${position.title}?` },
{ label: 'Who matches this role?', prompt: `Who matches ${position.title}?` },
],
};
}
return {
doc: doc(
heading(`Assignment preview — ${position.title}`, `${plan.selected.length} proposed`),
...plan.selected.map((row, i) => candidateBlock(row, i + 1, { applications: context.applications, position })),
text(
`**${demand.assigned} of ${demand.required}** assigned now · `
+ `**${plan.after} of ${plan.required}** after · `
+ `**${plan.remainingAfter}** remaining`
),
note('Nothing is written until you confirm. I re-check availability at that moment, so anyone taken in the meantime drops out.')
),
followUp: [
{ label: 'Confirm assignment', prompt: `Confirm assignment for ${position.title}` },
{ label: 'Cancel', prompt: `Who matches ${position.title}?` },
],
};
}
/**
* One candidate, examined — inside the panel.
*
* Deliberately not a copy of the Candidate Profile page. It carries only what a
* hiring decision needs: how they match, whether they are free, what they have
* done, and where their application stands. The full record stays one explicit
* click away, and remains the source of truth.
*
* The actions offered depend on the person's real state, so the panel never
* offers an assignment it would then have to refuse.
*/
export function candidateDetail(position, row, context) {
const profile = row.profile || {};
const applications = context.applications || [];
const application = applications.find(
(a) => String(a.email || '').toLowerCase() === String(row.email || '').toLowerCase()
&& a.job_posting_id === position.id
) || applications.find(
(a) => String(a.email || '').toLowerCase() === String(row.email || '').toLowerCase()
) || null;
const facts = [
profile.experience_years ? `${profile.experience_years} years experience` : null,
profile.current_position || profile.desired_position || null,
profile.address || null,
(profile.certifications || []).length ? (profile.certifications || []).join(', ') : null,
].filter(Boolean);
const assignable = row.availability.available && !row.match.gaps.length;
/* Actions follow the state. An unavailable person is not offered an
assignment; somebody with no record is not offered a record to open. */
const followUp = [
assignable
? { label: 'Assign to position', prompt: `Assign ${row.name} to ${position.title}` }
: { label: 'Why not eligible?', prompt: `Can ${row.name} be assigned to ${position.title}?` },
{ label: '← Back to matches', prompt: `Who matches ${position.title}?` },
application
? { label: 'View full profile', prompt: `Open the full profile for ${row.name}` }
: null,
].filter(Boolean);
return {
doc: doc(
heading(`${row.name} — ${row.score}% match`, `Evaluating for ${position.title}`),
insights([{
tone: row.strong ? 'success' : row.availability.available ? 'info' : 'warning',
title: 'Match',
body: reasonsFor(row).join(' · '),
}]),
facts.length ? insights([{ tone: 'neutral', title: 'Background', body: facts.join(' · ') }]) : null,
insights([{
tone: row.availability.available ? 'success' : 'warning',
title: 'Availability',
body: row.availability.available
? `Free when this starts (${startLabel(position)})`
: row.availability.reason,
}]),
insights([{
tone: application ? 'info' : 'neutral',
title: 'Application',
body: application
? `${application.status.replace(/_/g, ' ')} · applied ${new Date(application.created_date).toLocaleDateString()}`
: 'No application on file for this role — assigning them creates one.',
}]),
assignable
? null
: note('I will not offer an assignment the engine would refuse. The reason is above.')
),
followUp,
};
}
/**
* A preview for one named person.
*
* Separate from the ranked preview because the question is different: the admin
* has already chosen, and what they need is whether that choice holds. So this
* validates rather than recommends — and refuses, with the engine's reason, when
* the person is committed elsewhere or short of a required level.
*/
export function namedAssignmentPreview(position, row, context) {
const demand = demandFor(position, context);
if (!row.availability.available) {
return {
doc: doc(
heading(`${row.name} cannot take ${position.title}`),
insights([{ tone: 'warning', title: row.availability.reason, body: reasonsFor(row).join(' · ') }]),
note('Availability is measured against this position’s start date. Ask who is available if you want the people who can take it.')
),
followUp: [{ label: 'Who is available?', prompt: `Who is available for ${position.title}?` }],
};
}
if (row.match.gaps.length) {
return {
doc: doc(
heading(`${row.name} is short of ${position.title}`, `${row.score}% match`),
insights([{
tone: 'warning',
title: row.match.gaps.map((g) => `${g.name}: ${g.heldLabel} → needs ${g.requiredLabel}`).join(' · '),
body: reasonsFor(row).join(' · '),
}]),
note('I will not put somebody forward as qualified when the engine says they are not. Assign them anyway by naming the gap you are willing to accept, or ask who does meet it.')
),
followUp: [{ label: 'Who fully matches?', prompt: `Who matches ${position.title}?` }],
};
}
return {
doc: doc(
heading('Assignment ready', `${row.name} → ${position.title}`),
candidateBlock(row, 1, { applications: context.applications, position }),
demand.declared
? text(`**${demand.assigned} of ${demand.required}** assigned now · **${demand.assigned + 1}** after · **${Math.max(0, demand.remaining - 1)}** remaining`)
: text('This position states no headcount, so there is no coverage figure to move.'),
note('Nothing is written until you confirm.')
),
followUp: [
{ label: 'Confirm assignment', prompt: `Confirm assignment of ${row.name} to ${position.title}` },
{ label: 'Cancel', prompt: `Who matches ${position.title}?` },
],
};
}
/** The single-person plan, re-derived at confirmation. */
export function namedAssignmentToExecute(position, row, context) {
if (!row || !row.availability.available || row.match.gaps.length) return null;
const demand = demandFor(position, context);
return {
position,
workers: [{ email: row.email, name: row.name, score: row.score }],
before: demand.assigned,
after: demand.assigned + 1,
required: demand.required,
remainingAfter: Math.max(0, demand.remaining - 1),
};
}
/**
* The plan, re-derived at the moment of confirmation.
*
* Returned to the caller to execute through the existing mutation. Re-deriving
* rather than replaying a stored preview is what makes the confirmation honest:
* the people written are the people who are still free.
*/
export function assignmentToExecute(position, context) {
const plan = prepareAssignment(position, context);
if (plan.blocked || !plan.selected.length) return null;
return {
position,
workers: plan.selected.map((row) => ({ email: row.email, name: row.name, score: row.score })),
before: plan.before,
after: plan.after,
required: plan.required,
remainingAfter: plan.remainingAfter,
};
}
/* ── Interview ─────────────────────────────────────────────────────────── */
/**
* The application a person holds for this role, or null.
*
* Everything about interviewing keys on this record: it is the subject the AI
* interview takes, and the thing a status transition moves.
*/
export function applicationFor(row, position, context) {
const email = String(row?.email || '').toLowerCase();
if (!email) return null;
return (context.applications || []).find(
(a) => a.job_posting_id === position?.id && String(a.email || '').toLowerCase() === email
) || null;
}
/**
* Interview setup, previewed.
*
* The blockers are real states, not guesses: somebody with no application for
* this role has not entered its pipeline, and somebody already at interview
* stage does not need moving there again. Both are reported rather than
* papered over with a second record.
*/
export function interviewPreview(position, row, context) {
const application = applicationFor(row, position, context);
if (!application) {
return {
doc: doc(
text(`**${row.name}** has no application for **${position.title}**, so there is nothing to interview against.`),
note('Assigning them creates the application, and the interview can be set up straight after.')
),
followUp: [{ label: 'Assign to position', prompt: `Assign ${row.name} to ${position.title}` }],
};
}
if (application.status === 'interview') {
return {
doc: doc(
heading('Already at interview stage', `${row.name} · ${position.title}`),
text('The AI interview can be started from their candidate record.'),
insights([{
tone: 'info',
title: 'Open the AI interview',
body: `${row.name} — ${position.title}`,
to: `/admin/candidates/${application.id}`,
}])
),
followUp: [{ label: '← Back to candidate', prompt: `Show ${row.name} for ${position.title}` }],
};
}
const requirements = (position.skill_requirements || [])
.map((r) => `${skillName(r.skill_id)} — ${levelLabel(r.level)}`)
.join(' · ');
return {
doc: doc(
heading('Interview setup', `${row.name} → ${position.title}`),
insights([{
tone: 'info',
title: 'AI Interview',
body: requirements
? `Scored against this role's own requirements: ${requirements}`
: 'This position defines no skill requirements, so the interview covers the role generally.',
}]),
insights([{
tone: 'neutral',
title: 'Application',
body: `Currently ${String(application.status).replace(/_/g, ' ')} · moving to interview`,
}]),
note('This moves the application to interview stage. The interview itself is conducted by the AI interviewer on the candidate’s record, which writes the result.')
),
followUp: [
{ label: 'Confirm interview', prompt: `Confirm interview for ${row.name} at ${position.title}` },
{ label: '← Back to candidate', prompt: `Show ${row.name} for ${position.title}` },
],
};
}
/** The interview step to execute, or null when it cannot proceed. */
export function interviewToExecute(position, row, context) {
const application = applicationFor(row, position, context);
if (!application || application.status === 'interview') return null;
return { position, row, application };
}
/** What to say once the application has actually moved. */
export const interviewDone = (plan) => doc(
heading('Interview ready', `${plan.row.name} · ${plan.position.title}`),
text(`**${plan.row.name}** is now at interview stage for **${plan.position.title}**.`),
insights([{
tone: 'success',
title: 'Open the AI interview',
body: 'The AI interviewer scores against this role’s requirements and writes the result to their record.',
to: `/admin/candidates/${plan.application.id}`,
}]),
note('Candidates, the position pipeline and the activity log all reflect this now.')
);
export const interviewFailed = () => doc(
text('I could not move that application to interview stage.'),
note('Nothing was changed. Open the candidate’s record and try from there.')
);
/** What to say once the write has actually happened. */
export const assignmentDone = (plan) => doc(
heading('Assignment complete', `${plan.workers.length} assigned to ${plan.position.title}`),
list(plan.workers.map((w) => `${w.name} — ${w.score}% match`)),
text(
`**${plan.position.title}** is now **${plan.after} of ${plan.required}** assigned · `
+ `**${plan.remainingAfter}** remaining.`
),
note('The position, the applications and the activity log have been updated.')
);
/** The next step to offer once an assignment lands. */
export const assignmentFollowUp = (plan) => [
plan.workers.length === 1
? { label: 'Set up interview', prompt: `Set up an interview for ${plan.workers[0].name} at ${plan.position.title}` }
: { label: 'Who is ready for interview?', prompt: 'Who is ready for interview?' },
{ label: '← Back to matches', prompt: `Who matches ${plan.position.title}?` },
];
/** What to say when the write could not proceed. */
export const assignmentFailed = () => doc(
text('I could not complete that assignment.'),
note('Nothing was written. The people I proposed may have been taken since the preview — ask again and I will re-check.')
);

View File

@@ -1,174 +0,0 @@
/**
* The YAML subset skill frontmatter is allowed to use.
*
* Skills gained declarative UI configuration, which is nested — `ui:` holds a
* page, which holds sections, which hold their own options. The old parser read
* flat `key: value` pairs and one level of `- item`, so nesting was impossible
* to express.
*
* This is deliberately a *subset*, not a YAML library:
*
* - block maps and block sequences, nested to any depth
* - scalars: strings, integers, floats, booleans, null
* - quoted strings, for values containing `:` or `#`
* - `- key: value` — a mapping that starts on the dash
* - `#` comments, and blank lines
*
* Everything else — anchors, aliases, merge keys, multi-document files, flow
* mappings, block scalars, tags — is not supported and is not silently
* half-read: an unparseable line raises, so a definition either means what it
* says or is rejected with a line number.
*
* It returns plain data and nothing else. There is no code path from this file
* to evaluation of any kind: no `eval`, no `Function`, no dynamic import, no
* JSON with a reviver. A skill definition is configuration, and this is the
* boundary that keeps it configuration.
*/
/** `true` / `false` / `null` / numbers, or the string as written. */
function toScalar(raw) {
const value = String(raw).trim();
if (value === '' || value === '~' || value === 'null') return null;
if (value === 'true') return true;
if (value === 'false') return false;
/* Quoted: taken literally, which is how a value containing `:` or `#` is
written. No escape processing beyond the doubled quote. */
const quoted = /^(['"])([\s\S]*)\1$/.exec(value);
if (quoted) return quoted[2].replace(new RegExp(quoted[1] + quoted[1], 'g'), quoted[1]);
if (/^-?\d+$/.test(value)) return Number(value);
if (/^-?\d*\.\d+$/.test(value)) return Number(value);
/* An unquoted trailing comment is a comment. `#` inside a word is not. */
return value.replace(/\s+#.*$/, '').trim();
}
/** One line, reduced to what the parser needs to decide. */
function readLines(source) {
return String(source)
.split(/\r?\n/)
.map((text, i) => ({ text, line: i + 1 }))
.filter(({ text }) => text.trim() !== '' && !/^\s*#/.test(text))
.map(({ text, line }) => ({
line,
indent: text.match(/^\s*/)[0].replace(/\t/g, ' ').length,
content: text.trim(),
}));
}
/**
* Parses one block at `indent` or deeper, starting at `cursor.i`.
*
* Returns a map or an array depending on what the first line at this level is,
* which is how YAML itself decides. Recursion handles nesting; the cursor is
* shared so a child can consume the lines it owns.
*/
function parseBlock(lines, cursor, indent) {
const first = lines[cursor.i];
if (!first) return null;
return first.content.startsWith('- ')
|| first.content === '-'
? parseSequence(lines, cursor, indent)
: parseMapping(lines, cursor, indent);
}
function parseSequence(lines, cursor, indent) {
const out = [];
while (cursor.i < lines.length) {
const { content, indent: at, line } = lines[cursor.i];
if (at < indent) break;
if (at > indent) throw new Error(`Unexpected indentation on line ${line}`);
if (!content.startsWith('-')) break;
const rest = content.replace(/^-\s*/, '');
cursor.i += 1;
if (rest === '') {
/* `-` alone: the item is the indented block beneath it. */
out.push(cursor.i < lines.length && lines[cursor.i].indent > indent
? parseBlock(lines, cursor, lines[cursor.i].indent)
: null);
continue;
}
/* `- key: value` opens a mapping whose first key sits on the dash. The
remaining keys are indented to where that key started. */
const pair = /^([A-Za-z0-9_.-]+):\s*(.*)$/.exec(rest);
if (pair) {
const keyIndent = indent + (content.length - rest.length);
const item = {};
const [, key, value] = pair;
item[key] = value === ''
&& cursor.i < lines.length
&& lines[cursor.i].indent > indent
? parseBlock(lines, cursor, lines[cursor.i].indent)
: toScalar(value);
while (cursor.i < lines.length && lines[cursor.i].indent === keyIndent
&& !lines[cursor.i].content.startsWith('- ')) {
Object.assign(item, parseMapping(lines, cursor, keyIndent));
}
out.push(item);
continue;
}
out.push(toScalar(rest));
}
return out;
}
function parseMapping(lines, cursor, indent) {
const out = {};
while (cursor.i < lines.length) {
const { content, indent: at, line } = lines[cursor.i];
if (at < indent) break;
if (at > indent) throw new Error(`Unexpected indentation on line ${line}`);
if (content.startsWith('- ')) break;
const pair = /^([A-Za-z0-9_.-]+):\s*(.*)$/.exec(content);
if (!pair) throw new Error(`Line ${line} is not \`key: value\`: ${content}`);
const [, key, value] = pair;
cursor.i += 1;
if (value !== '') {
out[key] = toScalar(value);
continue;
}
/* An empty value means the value is the block below — or nothing. */
const next = lines[cursor.i];
out[key] = next && next.indent > indent
? parseBlock(lines, cursor, next.indent)
: null;
}
return out;
}
/**
* A YAML document, as plain data.
*
* Throws on anything it cannot read rather than guessing, so a malformed
* definition is reported to its author instead of being registered in a shape
* nobody intended.
*/
export function parseYaml(source) {
const lines = readLines(source);
if (!lines.length) return {};
const cursor = { i: 0 };
const value = parseBlock(lines, cursor, lines[0].indent);
if (cursor.i < lines.length) {
throw new Error(`Unexpected indentation on line ${lines[cursor.i].line}`);
}
return value;
}

View File

@@ -1,73 +0,0 @@
// Derives the Talent "credit score" dimensions and motivational metrics
// from a WorkerProfile. Keeps the UI honest by mapping to real fields only.
const clamp = (n, min = 0, max = 100) => Math.max(min, Math.min(max, Math.round(Number(n) || 0)));
export function getTalentScore(profile = {}) {
return clamp(profile.krow_score || 0);
}
// Career Score — FICO-style 300–850 scale, the number shown on the KROW ID.
export const toFICO = (s) => 300 + Math.round((clamp(s) / 100) * 550);
export function getStars(profile = {}) {
const s = getTalentScore(profile);
if (s >= 90) return 5;
if (s >= 75) return 4;
if (s >= 60) return 3;
if (s >= 40) return 2;
if (s > 0) return 1;
return 0;
}
// Market value uplift ($/hr) derived from Talent Score
export function getMarketValue(profile = {}) {
const s = getTalentScore(profile);
return Math.max(0, Math.round((s - 70) / 5));
}
// Approximate growth this month (motivational, derived from XP)
export function getGrowth(profile = {}) {
const xp = Number(profile.xp) || 0;
return clamp(Math.round(xp / 25), 0, 99);
}
export function getReputation(profile = {}) {
const s = profile || {};
const courses = s.completed_courses || [];
const learning = clamp(courses.length * 8 + (Number(s.xp) || 0) / 10);
return [
{ key: 'talent', label: 'Talent Score', value: getTalentScore(s), big: true },
{ key: 'reliability', label: 'Reliability', value: clamp(s.reliability_score) },
{ key: 'attendance', label: 'Attendance', value: clamp(s.attendance_score) },
{ key: 'communication', label: 'Communication', value: clamp(s.performance_score) },
{ key: 'leadership', label: 'Leadership', value: clamp(s.leadership_potential) },
{ key: 'learning', label: 'Learning', value: learning },
{ key: 'problem', label: 'Problem Solving', value: clamp(s.ai_interview_score) },
];
}
// Weight each reputation factor like a credit score (FICO-style, sums to 100)
export const FACTOR_WEIGHTS = {
reliability: 25,
attendance: 20,
communication: 15,
leadership: 15,
learning: 15,
problem: 10,
};
// Score band — the "credit rating" tier for the talent
export function getScoreBand(score = 0) {
if (score >= 90) return { label: 'Elite', color: '#0838E0' };
if (score >= 75) return { label: 'Excellent', color: '#16A34A' };
if (score >= 60) return { label: 'Solid', color: '#2563EB' };
if (score >= 40) return { label: 'Building', color: '#D97706' };
if (score > 0) return { label: 'New', color: '#9CA3AF' };
return { label: 'No score yet', color: '#D1D5DB' };
}
export function getFactors(profile = {}) {
return getReputation(profile)
.filter((d) => !d.big)
.map((d) => ({ ...d, weight: FACTOR_WEIGHTS[d.key] || 0 }));
}

View File

@@ -1,33 +0,0 @@
// Shared employer-facing talent insights: ESAT, recent job history, and
// client endorsements. Used by both the talent card and the talent detail
// modal so the two stay in sync.
// ESAT — Employee Skills Assessment Transcript; derived from AI interview score
export function esatTag(profile) {
const s = Math.round(profile?.ai_interview_score || 0);
if (s > 0) return { label: `${s}/100`, cls: 'bg-[#FFF7ED] text-[#B45309]' };
return { label: 'Pending', cls: 'bg-[#F3F4F6] text-[#6B7280]' };
}
// Recent job history (last 3 years). experience entries carry a duration
// (`years`) rather than dates, so we surface the most recent up to 3.
export function recentJobs(profile) {
return (profile?.experience || []).slice(0, 3).filter((e) => e && (e.role || e.company));
}
// Up to 3 client endorsements, deterministically derived from companies the
// worker has been placed at, rated off their aggregate client rating.
export function clientEndorsements(profile) {
const companies = (profile?.experience || [])
.map((e) => e?.company)
.filter(Boolean);
const rating = profile?.client_rating || profile?.supervisor_rating || 5;
const stars = Math.max(1, Math.min(5, Math.round(rating)));
const roles = ['Event Director', 'Operations Lead', 'GM', 'Owner', 'HR Partner'];
if (companies.length === 0) return [];
return companies.slice(0, 3).map((c, i) => ({
company: c,
role: roles[i % roles.length],
stars,
}));
}

View File

@@ -1,53 +0,0 @@
import { base44 } from '@/api/base44Client';
let _cachedUser = null;
let _userFetched = false;
async function getCurrentUser() {
if (_userFetched) return _cachedUser;
try {
_cachedUser = await base44.auth.me();
} catch {
_cachedUser = null;
}
_userFetched = true;
return _cachedUser;
}
/** Best-effort activity logging — never throws, never blocks the main flow. */
export async function logActivity(eventType, extra = {}) {
try {
const me = await getCurrentUser();
const record = {
event_type: eventType,
user_email: me?.email || extra.email || 'anonymous',
user_name: me?.full_name || extra.name || '',
account_type: extra.account_type || me?.account_type || 'unknown',
details: extra.details || '',
/**
* The records this event is about.
*
* An event that says only "a candidate was contacted" cannot be read back
* against the position it was about, so the ids travel with it. They are
* written only when supplied — an event with no application does not get
* an `application_id: null` that later reads as a missing link.
*
* Kept as flat fields rather than a metadata blob so the store's own
* `filter({ position_id })` can find them without a custom query path.
*/
...(extra.position_id ? { position_id: extra.position_id } : {}),
...(extra.application_id ? { application_id: extra.application_id } : {}),
...(extra.candidate_id ? { candidate_id: extra.candidate_id } : {}),
...(extra.interview_id ? { interview_id: extra.interview_id } : {}),
...(extra.worker_email ? { worker_email: extra.worker_email } : {}),
...(extra.metadata ? { metadata: extra.metadata } : {}),
};
await base44.entities.UserActivity.create(record);
base44.analytics.track({
eventName: eventType,
properties: { account_type: record.account_type },
});
} catch {
// silent — tracking must not break user flows
}
}

View File

@@ -1,574 +0,0 @@
/**
* Workforce allocation: who is needed where, who is actually free, and which
* demand gets the people first.
*
* `skillGraph.js` answers "can this person do this work". This module answers
* the questions that come after it, and they are the ones that decide a real
* roster:
*
* demand how many people a position needs, has, and still lacks
* availability whether a person is free *when the work starts*, and free
* long enough to see it out
* priority which position gets the scarce people first, and why
*
* Two rules shape everything here.
*
* **Availability is a window, not a flag.** A person committed to a six-month
* role is not "unavailable" — they are unavailable *until a date*. Treating it
* as a boolean is what makes a system recommend someone who is already on a
* floor somewhere else, so every check below is against a start date.
*
* **Every score carries its reasons.** An allocation moves a person's work and a
* company's staffing; a number nobody can interrogate is not usable for that.
* Every function that ranks returns the reasons alongside the rank.
*
* Reads the existing entities only — JobPosting, JobApplication, WorkerProfile,
* Staff and the Assignment records the store now carries. Nothing here writes.
*/
import { matchPosition, recommendTraining, verifiedLevels } from './skillGraph';
const DAY_MS = 86400000;
const MONTH_MS = DAY_MS * 30;
/* ── Dates ─────────────────────────────────────────────────────────────── */
/** A position's start, as a date. No start date means "as soon as possible". */
export function startsAt(position, today = new Date()) {
return position?.start_date ? new Date(position.start_date) : new Date(today);
}
/** When the work ends, or null when it is open-ended. */
export function endsAt(position, today = new Date()) {
const months = position?.duration_months;
if (months == null) return null;
return new Date(startsAt(position, today).getTime() + months * MONTH_MS);
}
/** Days from now until the work starts. Negative or zero means it is live. */
export const daysUntilStart = (position, today = new Date()) =>
Math.round((startsAt(position, today).getTime() - today.getTime()) / DAY_MS);
/** "Immediately", "in 6 days", "in 3 weeks" — the phrasing answers use. */
export function startLabel(position, today = new Date()) {
const days = daysUntilStart(position, today);
if (days <= 0) return 'immediately';
if (days === 1) return 'tomorrow';
if (days < 14) return `in ${days} days`;
if (days < 60) return `in ${Math.round(days / 7)} weeks`;
return `in ${Math.round(days / 30)} months`;
}
/* ── Demand ────────────────────────────────────────────────────────────── */
/**
* What a position needs, has and still lacks.
*
* "Assigned" counts live assignment records plus anyone already hired onto the
* role, because both are people who are actually on it — a system that counted
* only one of them would report a gap the floor does not have.
*/
export function demandFor(position, { assignments = [], staff = [] } = {}) {
/**
* Whether this position states how many people it wants.
*
* Most records predate the field, and a position that never declared a
* headcount has no demand figure — it has an unknown one. Callers read
* `declared` and omit the demand entirely rather than presenting the fallback
* of 1 as though the employer had asked for one person.
*/
const declared = Number.isFinite(Number(position?.headcount)) && Number(position.headcount) > 0;
const required = declared ? Number(position.headcount) : 1;
const assigned = assignments.filter(
(a) => a.job_posting_id === position?.id && a.status === 'active'
);
/* Hired staff who never got an explicit assignment record still occupy a
slot. Counted by email so the two sources cannot double-count one person. */
const assignedEmails = new Set(assigned.map((a) => String(a.worker_email || '').toLowerCase()));
const hired = staff.filter(
(s) => s.job_posting_id === position?.id
&& !assignedEmails.has(String(s.email || '').toLowerCase())
);
const filled = assigned.length + hired.length;
return {
declared,
required,
assigned: filled,
remaining: Math.max(0, required - filled),
filledPct: required ? Math.round((filled / required) * 100) : 0,
assignments: assigned,
isFull: filled >= required,
};
}
/* ── Availability ──────────────────────────────────────────────────────── */
/**
* Is this person free for this work, and if not, when do they come free?
*
* The commitment that matters is the one covering the position's start date.
* Someone finishing a role next Tuesday is available for work starting next
* month — reporting them as "committed" would hide a person the roster needs.
*/
export function availabilityOf(profile, position, { assignments = [], today = new Date() } = {}) {
const email = String(profile?.email || '').toLowerCase();
const start = startsAt(position, today);
const commitments = assignments.filter(
(a) => String(a.worker_email || '').toLowerCase() === email && a.status === 'active'
);
/* A commitment blocks this work when it is still running when the work
starts. Open-ended commitments (`ends_at: null`) block everything. */
const blocking = commitments.filter((a) => !a.ends_at || new Date(a.ends_at) > start);
if (!blocking.length) {
return { available: true, committedUntil: null, blockedBy: null, reason: 'Free when this starts' };
}
/* The one that clears last decides when they are actually free. */
const openEnded = blocking.find((a) => !a.ends_at);
const until = openEnded
? null
: new Date(Math.max(...blocking.map((a) => new Date(a.ends_at).getTime())));
return {
available: false,
committedUntil: until,
blockedBy: blocking[0],
reason: until
? `Committed until ${until.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}`
: 'Committed to an open-ended assignment',
};
}
/**
* Can this person cover the whole run?
*
* A two-week availability against a one-year role is a real mismatch, and the
* spec is explicit that duration must matter. Someone free open-endedly covers
* anything; the penalty only applies when their own availability actually ends
* before the work does.
*/
export function durationFitOf(profile, position, { today = new Date() } = {}) {
const end = endsAt(position, today);
if (!end) {
/* Open-ended work. Anyone with a stated end to their availability is a
partial fit for it, and anyone without is a full one. */
const free = profile?.available_until ? new Date(profile.available_until) : null;
if (!free) return { covers: true, ratio: 1, note: null };
const months = Math.max(0, (free.getTime() - startsAt(position, today).getTime()) / MONTH_MS);
return {
covers: false,
ratio: Math.min(1, months / 12),
note: `Available for about ${Math.round(months)} more months on open-ended work`,
};
}
const free = profile?.available_until ? new Date(profile.available_until) : null;
if (!free || free >= end) return { covers: true, ratio: 1, note: null };
const needed = end.getTime() - startsAt(position, today).getTime();
const have = Math.max(0, free.getTime() - startsAt(position, today).getTime());
const ratio = needed ? Math.max(0, Math.min(1, have / needed)) : 1;
return {
covers: false,
ratio,
note: `Available for ${Math.round(have / DAY_MS)} of the ${Math.round(needed / DAY_MS)} days this runs`,
};
}
/* ── Candidate and workforce pool ──────────────────────────────────────── */
/**
* How well a candidate meets the requirements this position actually states.
*
* The fallback for somebody with no worker profile — which is most candidates.
* It compares fields both records genuinely carry: the minimum experience,
* English level and certifications the position asks for, against the ones the
* application states. Nothing is inferred and nothing is invented; a
* requirement the position does not state is not scored, and an answer the
* application does not give counts as unmet rather than assumed.
*
* Returns `null` when the position states none of these, because then there is
* nothing to measure and a number would be decoration.
*/
const ENGLISH_ORDER = ['basic', 'conversational', 'fluent', 'native'];
function statedRequirementMatch(position, application) {
const lines = [];
const required = Number(position?.min_experience_years) || 0;
if (required > 0) {
const held = Number(application?.years_experience) || 0;
lines.push({
skillId: 'experience',
name: 'Experience',
met: held >= required,
heldLabel: `${held} ${held === 1 ? 'year' : 'years'}`,
requiredLabel: `${required} ${required === 1 ? 'year' : 'years'}`,
});
}
const englishNeeded = ENGLISH_ORDER.indexOf(String(position?.english_required || '').toLowerCase());
if (englishNeeded > 0) {
const heldIndex = ENGLISH_ORDER.indexOf(String(application?.english_level || '').toLowerCase());
const label = (i) => (i < 0 ? 'Not stated' : ENGLISH_ORDER[i][0].toUpperCase() + ENGLISH_ORDER[i].slice(1));
lines.push({
skillId: 'english',
name: 'English',
met: heldIndex >= englishNeeded,
heldLabel: label(heldIndex),
requiredLabel: label(englishNeeded),
});
}
const certsNeeded = position?.certifications_required || [];
if (certsNeeded.length) {
const held = new Set((application?.certifications || []).map((c) => String(c).toLowerCase()));
const missing = certsNeeded.filter((c) => !held.has(String(c).toLowerCase()));
const heldList = certsNeeded.filter((c) => held.has(String(c).toLowerCase()));
lines.push({
skillId: 'certifications',
name: 'Certifications',
met: missing.length === 0,
/* What they hold, not what they are missing — the requirement half of the
line already names what is needed, and saying it twice reads as two
different facts. */
heldLabel: missing.length === 0
? 'All on file'
: heldList.length ? `Holds ${heldList.join(', ')}` : 'None on file',
requiredLabel: certsNeeded.join(', '),
});
}
if (!lines.length) return null;
const met = lines.filter((l) => l.met);
return {
score: Math.round((met.length / lines.length) * 100),
lines,
met,
gaps: lines.filter((l) => !l.met),
band: met.length === lines.length ? 'Meets stated requirements' : 'Partial requirement match',
tone: met.length === lines.length ? 'success' : met.length ? 'warning' : 'risk',
};
}
/**
* The candidates for this position, ranked, with the reasoning attached.
*
* ── Where the pool comes from ────────────────────────────────────────────
*
* It starts from the **candidate records** — the applications the Candidates
* page lists — and enriches each one with a worker profile *if that person has
* one*. Never the other way round. Three concepts stay separate:
*
* candidate a person on the Candidates page. The identity, and the id
* every link uses.
* application that person's application to a role. Present or not.
* worker profile verified skill levels, availability, training. Optional
* supporting intelligence, held by 9 of 24 people here.
*
* Building the pool from worker profiles inverted that: it recommended people
* the Candidates page had never heard of, and — worse — dropped real candidates
* for the crime of not having a training record. A candidate is a candidate
* whether or not the workforce system knows anything else about them.
*
* ── How a row is scored ──────────────────────────────────────────────────
*
* Two bases, and a row says which one it used, because they are not the same
* claim:
*
* `verified` the skill graph, scored on training this person actually
* completed. Timing adjusts it, since somebody who cannot start
* is not the better answer.
* `stated` the position's own stated requirements against what the
* application states. Real fields, weaker evidence.
*
* A candidate the position gives nothing to measure against is still returned,
* marked unscored, rather than being dropped or given an invented number.
*/
export function poolFor(position, {
profiles = [], applications = [], assignments = [], courses = [], staff = [], today = new Date(),
} = {}) {
const profileByEmail = new Map(
profiles.map((p) => [String(p.email || '').toLowerCase(), p])
);
/* One row per person, not per application: somebody who applied to three
roles is one candidate. Their record for this position is the application
to it when there is one, otherwise their most recent. */
const candidates = new Map();
for (const application of applications) {
const key = String(application.email || '').toLowerCase() || application.id;
const held = candidates.get(key);
const isForThisRole = application.job_posting_id === position?.id;
const heldIsForThisRole = held?.job_posting_id === position?.id;
if (!held
|| (isForThisRole && !heldIsForThisRole)
|| (isForThisRole === heldIsForThisRole
&& new Date(application.created_date) > new Date(held.created_date))) {
candidates.set(key, application);
}
}
return [...candidates.values()]
.map((candidate) => {
const email = String(candidate.email || '').toLowerCase();
const profile = profileByEmail.get(email) || null;
const application = candidate.job_posting_id === position?.id ? candidate : null;
/* Verified skills where the workforce system holds them; the position's
stated requirements where it does not. */
const verified = profile ? matchPosition(position, verifiedLevels(courses, profile)) : null;
const match = verified || statedRequirementMatch(position, candidate);
const basis = verified ? 'verified' : match ? 'stated' : 'none';
/* Availability is a workforce fact. Without a profile it is not known,
and "not known" is said rather than assumed either way. */
const availability = profile
? availabilityOf(profile, position, { assignments, today })
: { available: true, known: false, reason: 'Availability not on file' };
const duration = profile
? durationFitOf(profile, position, { today })
: { covers: true, ratio: 1, note: null };
/* Timing multiplies the skill score rather than replacing it — but only
where timing is actually known. A stated-requirement score is not
adjusted by an availability nobody has recorded. */
const timingFactor = profile
? (availability.available ? 1 : 0.25) * (0.6 + 0.4 * duration.ratio)
: 1;
const score = match ? Math.round(match.score * timingFactor) : null;
const reasons = [
match
? basis === 'verified'
? `${match.score}% skill match against this role's requirements`
: `${match.score}% of this role's stated requirements met`
: 'No requirements on this position to score against',
profile ? availability.reason : null,
duration.note,
application ? `Applied ${new Date(application.created_date).toLocaleDateString()}` : null,
].filter(Boolean);
return {
/* Identity: always the candidate record. */
candidate,
candidateId: candidate.id,
name: candidate.applicant_name,
email: candidate.email,
/* Enrichment: present only when this person has it. */
profile,
basis,
scored: Boolean(match),
match,
skillScore: match ? match.score : null,
score,
availability,
duration,
application,
applied: Boolean(application),
/* Only verified evidence earns "strong". A stated-requirement match is
real information, but it is not proof the person can do the work. */
strong: basis === 'verified' && match.score >= 75 && availability.available,
reasons,
recommendation: profile ? recommendTraining(position, profile, courses) : null,
};
})
/**
* Verified matches first, then stated ones, then candidates with nothing to
* score — so the strongest evidence leads and no real candidate is buried
* under an ordering rule they had no way to satisfy. Ties fall through to
* things that are also true: who can cover more of the run, and who has
* done this longer.
*/
.sort((a, b) => {
const rank = (row) => (row.basis === 'verified' ? 0 : row.basis === 'stated' ? 1 : 2);
return rank(a) - rank(b)
|| (b.score ?? -1) - (a.score ?? -1)
|| b.duration.ratio - a.duration.ratio
|| (b.candidate.years_experience || 0) - (a.candidate.years_experience || 0)
|| String(a.name).localeCompare(String(b.name));
});
}
/**
* The workforce picture for one position — the figures the detail page and
* Owliver both report, computed once so they cannot disagree.
*/
export function workforceStatusFor(position, context = {}) {
const demand = demandFor(position, context);
const pool = poolFor(position, context);
const today = context.today || new Date();
/**
* Applicants are counted from the application records for *this* position;
* the pool is every candidate, whichever role they applied to.
*
* Two different questions — "who applied here" and "who could do this work" —
* so each row is paired with its pool entry rather than the two being merged.
*/
const byEmail = new Map(pool.map((row) => [String(row.email || '').toLowerCase(), row]));
const applicants = (context.applications || [])
.filter((a) => a.job_posting_id === position?.id)
.map((application) => ({
application,
email: application.email,
name: application.applicant_name,
/* The engine's assessment, when this person is someone we can assess. */
match: byEmail.get(String(application.email || '').toLowerCase()) || null,
}));
const newToday = applicants.filter(
(a) => new Date(a.application.created_date).toDateString() === today.toDateString()
);
const strong = pool.filter((p) => p.strong);
return {
position,
demand,
pool,
/* Free for this work and not already on it. Availability is a workforce
fact, so this counts only people it is actually known for — a candidate
with no workforce record is not reported as free. */
existingAvailable: pool.filter(
(p) => p.availability.known !== false && p.availability.available && !p.applied
),
/* Candidates whose score rests on completed training rather than on what
their application states. Reported so a surface can say which. */
verified: pool.filter((p) => p.basis === 'verified'),
applicants,
newToday,
strong,
interviewReady: applicants.filter((a) => a.application.status === 'interview'),
/**
* Hired onto this role, from the application record.
*
* Reported separately from `demand.assigned` because they are different
* facts: hiring is a decision about a person, assignment is their presence
* on the roster. Someone can be hired and not yet assigned, and a card that
* conflated the two would report coverage the floor does not have.
*/
hired: applicants.filter((a) => a.application.status === 'hired'),
/* What is still missing after everyone free and qualified is counted. */
gap: Math.max(0, demand.remaining - strong.length),
};
}
/* ── Priority across companies ─────────────────────────────────────────── */
/**
* Which position gets the scarce people first.
*
* Four things decide it, and each contributes a stated number of points so the
* ranking can always be read back as a sentence:
*
* urgency what the employer declared
* timing work starting today outranks work starting next month
* shortage how many people are still missing, and what share of the role
* supply whether anyone is actually available to fill it
*
* Supply counts *against* priority when it is plentiful: a position with more
* strong matches than open slots does not need to be first in the queue, it
* needs somebody to press the button. What ranks highest is a role that starts
* soon, is badly short, and has few people who can fill it.
*/
export function prioritise(positions = [], context = {}) {
const today = context.today || new Date();
return positions
.filter((p) => p.status === 'active')
.map((position) => {
const status = workforceStatusFor(position, { ...context, today });
const { demand } = status;
if (!demand.remaining) return null;
const days = daysUntilStart(position, today);
const reasons = [];
let score = 0;
const urgency = { urgent: 30, high: 18, normal: 6 }[position.priority] ?? 6;
score += urgency;
if (position.priority === 'urgent') reasons.push('Flagged urgent by the employer');
/* Timing. Live work is the strongest signal in the model — every day it
runs short is a day the client is under-staffed. */
const timing = days <= 0 ? 35 : days <= 7 ? 26 : days <= 30 ? 14 : 5;
score += timing;
reasons.push(days <= 0 ? 'Starts immediately' : `Starts ${startLabel(position, today)}`);
/* Shortage, as both a count and a proportion: 18 of 30 missing is worse
than 2 of 30, and worse again than 18 of 200. */
const shortfall = demand.remaining / demand.required;
const shortage = Math.round(shortfall * 20) + Math.min(15, demand.remaining);
score += shortage;
reasons.push(`${demand.remaining} of ${demand.required} still unfilled`);
/* Supply. Scarcity raises priority; a full bench lowers it. */
const coverage = demand.remaining ? status.strong.length / demand.remaining : 1;
const supply = coverage >= 1 ? -10 : Math.round((1 - coverage) * 20);
score += supply;
reasons.push(
status.strong.length
? `${status.strong.length} strong ${status.strong.length === 1 ? 'match' : 'matches'} available now`
: 'No strong matches available'
);
return {
position,
status,
score: Math.max(0, score),
band: score >= 70 ? 'High' : score >= 45 ? 'Medium' : 'Low',
reasons,
breakdown: { urgency, timing, shortage, supply },
};
})
.filter(Boolean)
.sort((a, b) => b.score - a.score);
}
/* ── Preparing an assignment ───────────────────────────────────────────── */
/**
* The assignment Owliver would make, as a proposal — never as a write.
*
* Returns the people, the counts before and after, and nothing else: executing
* it is a separate, explicitly confirmed step (see `useAssignWorkers`). This
* function existing separately from the mutation is what makes "show me first"
* the only possible path rather than a convention someone has to remember.
*/
/** @param {any} position @param {any} [options] */
export function prepareAssignment(position, options = {}) {
const { count, ...context } = options || {};
const status = workforceStatusFor(position, context);
const take = Math.min(
count ?? status.demand.remaining,
status.demand.remaining,
status.strong.length
);
const selected = status.strong.slice(0, Math.max(0, take));
return {
position,
status,
selected,
before: status.demand.assigned,
after: status.demand.assigned + selected.length,
required: status.demand.required,
remainingAfter: Math.max(0, status.demand.required - status.demand.assigned - selected.length),
/* Why this is not simply "the top N": either nobody is free, or the role is
already full. Callers say which rather than showing an empty preview. */
blocked: selected.length === 0
? (status.demand.remaining === 0 ? 'full' : 'no_available_matches')
: null,
};
}