Frontend Code Walkthrough — Mirabelle Tutorial¶
This guide explains the React 18 frontend for a developer learning the codebase. Read it alongside the source files in files/frontend/src/.
Project Structure¶
files/frontend/
├── src/
│ ├── App.jsx # Root: routes, sidebar, context providers
│ ├── components/ # Page components (one per route)
│ │ ├── Dashboard.jsx # / — series table with sparklines
│ │ ├── TimeSeriesViewer.jsx # /series/:uid — drill-down charts
│ │ ├── ExceptionsDashboard.jsx # /exceptions — exception list
│ │ ├── ExceptionWorkflow.jsx # Exception detail overlay
│ │ ├── ProcessRunner.jsx # /processes — pipeline control
│ │ ├── Settings.jsx # /settings — parameters editor
│ │ ├── Segments.jsx # /segments
│ │ ├── ABCClassification.jsx # /abc
│ │ ├── CausalForecasting.jsx # /causal
│ │ ├── NpiManager.jsx # /npi
│ │ ├── Pipelines.jsx # /pipelines
│ │ ├── AlertOntology.jsx # /alert-ontology
│ │ ├── SupplyPlan.jsx # /supply
│ │ ├── ExceptionsDashboard.jsx # /exceptions
│ │ ├── LandingPage.jsx # Unauthenticated landing
│ │ ├── Login.jsx # /login
│ │ ├── UserManagement.jsx # /users
│ │ ├── AuditLog.jsx # /audit
│ │ ├── AdminPanel.jsx # /admin (superAdmin only)
│ │ └── ...shared components...
│ ├── contexts/ # React context providers
│ │ ├── AuthContext.jsx # JWT auth state
│ │ ├── PipelineContext.jsx # Active pipeline selection
│ │ ├── ThemeContext.jsx # Dark/light mode
│ │ └── LocaleContext.jsx # Number/date formatting
│ ├── context/ # Newer contexts (different folder)
│ │ └── ExceptionContext.jsx # Exception list + navigation state
│ ├── utils/
│ │ ├── api.js # Shared axios instance
│ │ ├── useSupplyParams.js # Supply API params hook
│ │ └── exceptionNav.js # Exception navigation helpers
│ └── tour/ # Shepherd.js guided tour
├── package.json
└── vite.config.js
The Axios Instance (utils/api.js)¶
Every API call in the application goes through this shared instance. Never import raw axios — always import this:
Here is the full implementation with annotations:
import axios from 'axios';
// Create an axios instance with the base URL pointing to the API.
// Vite proxies /api/* → http://localhost:8002 in dev mode.
// In production, the backend serves the built frontend from the same origin.
const api = axios.create({
baseURL: '/api',
});
// ── Request interceptor: attach JWT token ──
// Runs before EVERY outgoing request.
// Reads the token from localStorage and adds it as a Bearer header.
// If there is no token, no header is added — the backend will return 401.
api.interceptors.request.use((config) => {
const token = localStorage.getItem('mirabelle_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// ── Response interceptor: handle 401 + retry 503 ──
const MAX_503_RETRIES = 2;
api.interceptors.response.use(
(response) => response, // Pass through successful responses unchanged
async (error) => {
const config = error.config;
// Retry 503s (API cold-start data-loading delay) with exponential backoff.
// React Strict Mode intentionally aborts requests on cleanup — detect these
// via config.signal.aborted and do NOT retry them.
const isAborted = config?.signal?.aborted || error.code === 'ERR_CANCELED';
if (
error.response?.status === 503 &&
!isAborted &&
!(config._retryCount >= MAX_503_RETRIES)
) {
config._retryCount = (config._retryCount || 0) + 1;
// 800 ms first retry, 1600 ms second retry
await new Promise(r => setTimeout(r, 800 * config._retryCount));
return api(config); // Resubmit the original request
}
// On 401: clear stored credentials and redirect to login.
// This handles both expired tokens and tokens that were revoked server-side.
if (error.response?.status === 401) {
localStorage.removeItem('mirabelle_token');
localStorage.removeItem('mirabelle_user');
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
export default api;
Why a shared instance?
- Single auth header: Without it, every component would need to read the token from localStorage and attach the header manually. A shared interceptor does this once.
- Centralised 401 handling: Session expiry is handled in one place instead of every
catchblock. - 503 retry: Cold-start delays on WSL (where the API loads its data cache) would otherwise surface as errors to users. The transparent retry covers this without user-visible errors.
- Token key consistency: The token key (
mirabelle_token) and user key (mirabelle_user) are defined here and inAuthContext.jsx— nowhere else.
Context Architecture¶
AuthContext.jsx¶
Manages authentication state. Wrap the entire app in AuthProvider (done in App.jsx).
What it holds:
const value = {
user, // { id, email, name, role, account_id, account_name, ... }
loading, // true while validating the stored token on mount
isAdmin, // user.role === 'admin' || 'superadmin'
isSuperAdmin, // user.role === 'superadmin'
isAuthenticated, // !!user
canRunProcess, // user.can_run_process || isAdmin
canCreateOverride, // user.can_create_override || isAdmin
currentAccount, // { id: uuid, name: "Tenant Name" }
login, // (email, password, account_id?) → Promise<user>
loginWithMicrosoft, // (msalToken, account_id?) → Promise<user>
loginWithGoogle, // (googleCredential, account_id?) → Promise<user>
logout, // () → void
switchAccount, // (accountId) → Promise<user>
fetchAvailableAccounts, // (email) → Promise<accounts[]>
};
Token lifecycle:
Mount → read 'mirabelle_token' from localStorage
→ GET /api/auth/me to validate
→ setUser(res.data) or clear storage + setUser(null)
login() → POST /api/auth/login
→ localStorage.setItem('mirabelle_token', token)
→ setUser(userData)
logout() → POST /api/auth/logout (best-effort)
→ localStorage.removeItem('mirabelle_token')
→ setUser(null)
SuperAdmin two-phase login:
When a superAdmin logs in without specifying an account_id, the backend returns { status: 'select_account', accounts: [...] }. The login() function detects this and re-throws a structured error:
if (data.status === 'select_account') {
const err = new Error('select_account');
err.response = { data };
throw err;
}
The Login.jsx component catches this and shows an account picker. The user selects a tenant and calls login() again with the chosen account_id.
Usage:
import { useAuth } from '../contexts/AuthContext';
function MyComponent() {
const { user, isAdmin, logout } = useAuth();
return <button onClick={logout}>Sign out {user.name}</button>;
}
PipelineContext.jsx¶
Manages which pipeline is currently active in the toolbar dropdown. This is the single most important state variable in the app — almost every data-fetching call depends on it.
What it holds:
const value = {
pipelines, // full list from GET /api/scenario-pipeline
activePipelineId, // number | null — the currently selected pipeline_id
setActivePipelineId,// (id) → void — also persists to localStorage
activePipeline, // the full pipeline object for activePipelineId
loading, // true while fetching the pipeline list
};
Initialisation:
// Read stored pipeline_id from localStorage on mount
const [activePipelineId, setActivePipelineIdRaw] = useState(() => {
const stored = localStorage.getItem('mirabelle_pipeline');
return stored ? Number(stored) : null;
});
useEffect(() => {
api.get('/scenario-pipeline')
.then(r => {
// Deduplicate by name, keeping the lowest pipeline_id for each name
const raw = r.data || [];
const seen = new Set();
const list = raw
.sort((a, b) => a.pipeline_id - b.pipeline_id)
.filter(p => { if (seen.has(p.name)) return false; seen.add(p.name); return true; });
setPipelines(list);
// Auto-select first pipeline if stored id is gone
setActivePipelineIdRaw(prev => {
if (prev && list.find(p => p.pipeline_id === prev)) return prev;
const first = list[0]?.pipeline_id ?? null;
if (first != null) localStorage.setItem('mirabelle_pipeline', String(first));
return first;
});
});
}, []);
The activePipeline object is the full row from scenario_pipeline — including supply_scenario_id which is used by useSupplyParams().
Usage:
import { usePipeline } from '../contexts/PipelineContext';
function MyComponent() {
const { activePipelineId, activePipeline } = usePipeline();
useEffect(() => {
api.get('/series', { params: { pipeline_id: activePipelineId } });
}, [activePipelineId]);
}
ThemeContext.jsx¶
Manages dark/light mode via Tailwind's dark: class variants. Toggling dark mode adds or removes the dark class from the <html> element, which Tailwind uses to activate its dark: prefixed utilities.
The preference is persisted in localStorage (key mirabelle_theme) and also responds to the OS preference via prefers-color-scheme.
LocaleContext.jsx¶
Provides locale-aware number and date formatting. Components import useLocale() to get formatter functions rather than constructing Intl.NumberFormat everywhere.
What it holds:
const value = {
locale, // e.g. 'en-US', 'fr-FR', 'de-DE'
numberDecimals, // user preference (default 1) — controls decimal places in all grids
setNumberDecimals, // (n) → void — persists to localStorage
useFormatters, // hook that returns memoized formatter functions
};
The useFormatters() hook:
const { fmtNum, fmtCurrency, fmtPct, fmtDate } = useFormatters();
// fmtNum(value, decimals?) — formats a number with locale and numberDecimals
// decimals defaults to numberDecimals; can override (e.g. fmtNum(v, 4))
// Sets both minimumFractionDigits and maximumFractionDigits
// fmtCurrency(value) — formats as currency
// fmtPct(value) — formats as percentage (always 1 decimal)
// fmtDate(date) — formats a date string
Uniform grid number precision:
All editable grids in the application respect the numberDecimals setting from LocaleContext. The key infrastructure is:
| Component | Prop | Formatting |
|---|---|---|
ExcelGrid (ScenarioPlanner) |
locale, numberDecimals |
Display via formatNumber(val, locale, numberDecimals) |
RecordGrid (ScenarioPlanner) |
locale, numberDecimals |
Display via formatNumber(val, locale, numberDecimals) |
EditableCell / GroupedEditableCell (TimeSeriesViewer) |
numberDecimals |
Display via formatNumber(..., locale, numberDecimals) |
SortableTable |
useLocale() |
InlineEditCell uses formatNumber(val, locale, numberDecimals) |
MeioScenarios, ForecastParameterSets, DetailConfigPanelShared |
useFormatters() |
fmtNum(v) defaults to numberDecimals |
IOCell (KpiChip) |
useFormatters() |
fmtNum(v) defaults to numberDecimals |
SupplyPlan |
useFormatters() |
fmtNumL alias from useFormatters() |
The formatNumber(value, locale, maxDecimals=1, opts={}) function in utils/formatting.js sets minimumFractionDigits to maxDecimals by default, so 1,250 with numberDecimals=1 displays as 1,250.0.
Excel-like keyboard navigation:
All inline-editable cells support Tab/Arrow key navigation:
| Key | Behaviour |
|---|---|
| Tab | Commit edit, move to next cell |
| Shift+Tab | Move to previous cell |
| ArrowLeft/Right | Navigate when cursor is at text boundary (editing) or always (non-editing) |
| ArrowUp/Down | Navigate between rows |
| Enter | Commit edit |
| Escape | Cancel edit |
The SortableTable.InlineEditCell handles Tab via e.target.blur() (onBlur commits, browser moves focus). The TimeSeriesViewer.GroupedEditableCell uses handleAdjKeyDown with el.click() + el.focus() for Tab navigation to non-editing TD cells.
ExceptionContext.jsx¶
Manages the exception navigation state for the Exception Workflow overlay. This is the most complex context in the app.
What it holds:
const value = {
list, // exception_log items from the last fetch
setList, // update the list (called by ExceptionsDashboard)
position, // current position in the list (-1 = not navigating)
setPosition, // (n, pipelineId) → prefetches adjacent items
fromExceptions, // bool — true when navigating from exception list
setFromExceptions,
returnPosition, // bool — used to return to the list position on back
setReturnPosition,
filters, // { types, status, segment_id }
setFilters,
navigateToException,// (exception, navigate, pipelineId) → navigate to the right page
getTargetPage, // (exception) → route string ('/series/...' or '/supply')
getCached, // (itemId) → cached prefetch data or undefined
prefetch, // (positions[], pipelineId) → prefetch adjacent items
};
Prefetching:
When the user navigates to position N in the exception list, the context prefetches positions N-2, N-1, N+1, N+2 using GET /api/exception-log/preload. Results are stored in a Map keyed by item_id. When the user navigates to the next exception, its data is likely already cached and appears instantly.
const prefetch = useCallback(async (positions, pipelineId) => {
const toFetch = positions
.filter(i => i >= 0 && i < list.length) // bounds check
.map(i => list[i])
.filter(ex => ex && !cache.current.has(ex.item_id)) // not already cached
.map(ex => ex.item_id);
if (toFetch.length === 0) return;
const res = await api.get('/exception-log/preload', {
params: { pipeline_id: pipelineId, item_ids: toFetch.join(',') },
});
Object.entries(res.data).forEach(([itemId, data]) => {
cache.current.set(parseInt(itemId), data);
});
}, [list]);
Navigation routing:
const getTargetPage = useCallback((exception) => {
const types = exception.exception_types || [];
const uid = exception.unique_id || exception.item_id;
// Forecast and IO exceptions → series detail page
if (types.includes('forecast') || types.includes('io')) {
return `/series/${encodeURIComponent(uid)}`;
}
// Supply-only exceptions → supply plan
return '/supply';
}, []);
The Lazy Loading Pattern in App.jsx¶
Every route component is loaded lazily using React.lazy:
const Dashboard = lazy(() => import('./components/Dashboard'));
const TimeSeriesViewer = lazy(() => import('./components/TimeSeriesViewer'));
const ProcessRunner = lazy(() => import('./components/ProcessRunner'));
// ... and so on for every route
Wrapped in a <Suspense> boundary with a spinner:
const PageSpinner = () => (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
);
// In the route rendering:
<Suspense fallback={<PageSpinner />}>
<Dashboard />
</Suspense>
Why: Vite splits each lazy component into a separate JavaScript chunk. When the user navigates to /, only Dashboard.js is downloaded — TimeSeriesViewer.js, ProcessRunner.js, etc. are not. This keeps the initial bundle small and reduces time-to-interactive.
The PageErrorBoundary class component catches render errors in any route and shows a recovery UI rather than a blank screen.
The Supply Params Hook (useSupplyParams.js)¶
This hook is mandatory for any call to a /supply/* endpoint. It derives the pipeline_id and scenario_id from the active pipeline context.
import { usePipeline } from '../contexts/PipelineContext';
export function useSupplyParams() {
const { activePipelineId, activePipeline } = usePipeline();
return {
pipeline_id: activePipelineId,
scenario_id: activePipeline?.supply_scenario_id ?? null,
};
}
The mandatory pattern:
import { useSupplyParams } from '../utils/useSupplyParams';
import api from '../utils/api';
function SupplyOrdersTable() {
const supplyParams = useSupplyParams();
useEffect(() => {
api.get('/supply/orders', {
params: {
...supplyParams, // pipeline_id + scenario_id
run_id: currentRunId,
}
}).then(r => setOrders(r.data));
}, [supplyParams.pipeline_id, supplyParams.scenario_id, currentRunId]);
}
Why both IDs are required:
pipeline_idscopes the backend query to the correct tenant pipeline.scenario_id(specificallysupply_scenario_id) selects which supply scenario to read from — two pipelines may reference the same scenario, or one pipeline may have been run multiple times under different scenarios.
Without both, backend guards return empty data or resolve the wrong run.
Exceptions (do NOT use useSupplyParams):
- /supply/item-site — item-site master data, not run-scoped
- /supply/sales-returns CRUD — master data
Route Map and Provider Structure¶
The provider nesting order in App.jsx defines the dependency graph:
<ThemeProvider>
<LocaleProvider>
<AuthProvider> ← JWT token; must wrap everything
<PipelineProvider> ← Reads /api/scenario-pipeline; needs auth
<ExceptionProvider>← Exception navigation state
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={<ProtectedRoute><Layout /></ProtectedRoute>}>
<Route index element={<Dashboard />} />
<Route path="series/:unique_id" element={<TimeSeriesViewer />} />
<Route path="exceptions" element={<ExceptionsDashboard />} />
<Route path="processes" element={<ProcessRunner />} />
<Route path="settings" element={<Settings />} />
<Route path="segments" element={<Segments />} />
<Route path="abc" element={<ABCClassification />} />
<Route path="causal" element={<CausalForecasting />} />
<Route path="npi" element={<NpiManager />} />
<Route path="pipelines" element={<Pipelines />} />
<Route path="alert-ontology" element={<AlertOntology />} />
<Route path="supply" element={<SupplyPlan />} />
<Route path="users" element={<UserManagement />} />
<Route path="audit" element={<AuditLog />} />
<Route path="admin" element={<AdminPanel />} />
</Route>
</Routes>
</BrowserRouter>
</ExceptionProvider>
</PipelineProvider>
</AuthProvider>
</LocaleProvider>
</ThemeProvider>
ProtectedRoute checks isAuthenticated from AuthContext. If false, it redirects to /login.
Typical Data-Fetching Pattern¶
Almost every page component follows this pattern:
import { useState, useEffect } from 'react';
import api from '../utils/api';
import { usePipeline } from '../contexts/PipelineContext';
function ExamplePage() {
const { activePipelineId } = usePipeline();
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!activePipelineId) return; // Wait for pipeline context to load
const controller = new AbortController();
setLoading(true);
setError(null);
api.get('/series', {
params: { pipeline_id: activePipelineId, limit: 100 },
signal: controller.signal, // Allow cleanup to cancel in-flight requests
})
.then(r => setData(r.data))
.catch(err => {
if (err.code !== 'ERR_CANCELED') setError(err.message);
})
.finally(() => setLoading(false));
return () => controller.abort(); // Cleanup: cancel on unmount or dep change
}, [activePipelineId]);
if (loading) return <div>Loading…</div>;
if (error) return <div className="text-red-500">{error}</div>;
return <ul>{data.map(s => <li key={s.unique_id}>{s.unique_id}</li>)}</ul>;
}
Key points:
- Always depend on activePipelineId in the useEffect dependency array — re-fetch when the pipeline changes.
- Always create an AbortController and pass its signal to axios. Return controller.abort() from the cleanup. This prevents state updates on unmounted components and avoids stale data.
- React Strict Mode (dev) mounts components twice and intentionally aborts the first set of requests. The axios interceptor ignores aborted requests (ERR_CANCELED) and does not redirect to login.
The SortableTable Pattern¶
SortableTable.jsx is a shared component used across most list pages. It handles column sorting, click handlers, and applies consistent Tailwind classes.
import SortableTable from './SortableTable';
const columns = [
{ key: 'unique_id', label: 'Series', sortable: true },
{ key: 'best_method', label: 'Best Method', sortable: true },
{ key: 'mae', label: 'MAE', sortable: true, format: v => v?.toFixed(2) ?? '-' },
];
<SortableTable
columns={columns}
data={series}
onRowClick={row => navigate(`/series/${encodeURIComponent(row.unique_id)}`)}
/>
Inline SVG Sparklines¶
The Dashboard (/) renders a table of 500+ series. Each row needs a mini chart. Plotly is not used here — it is too heavy to instantiate 500 chart objects.
Instead, sparklines are rendered as inline SVG paths computed in JavaScript:
POST /api/sparklinesreturns compact[[date, value], ...]arrays for all visible series in one call.- Each series' values are normalised to the SVG viewport height.
- A
<polyline>element is rendered with those normalised points.
This approach renders 500 sparklines in ~5ms versus ~5000ms for 500 Plotly charts.
Full interactive charts (in TimeSeriesViewer) still use Plotly.js via react-plotly.js.
METHOD_COLORS Constant¶
All method colours are defined once in TimeSeriesViewer.jsx:
const METHOD_COLORS = {
AutoARIMA: '#6366f1', // indigo
AutoETS: '#059669', // green (also used for "best method" highlight)
AutoTheta: '#f59e0b', // amber
MSTL: '#8b5cf6', // violet
CrostonOptimized: '#ec4899', // pink
NHITS: '#06b6d4', // cyan
NBEATS: '#84cc16', // lime
PatchTST: '#f97316', // orange
TFT: '#14b8a6', // teal
DeepAR: '#ef4444', // red
TimesFM: '#a78bfa', // purple
};
The best method is always highlighted in emerald green (#059669). Import and use these constants whenever you add a new chart to keep all colours consistent.
The Exception Navigation Overlay¶
When a user is navigating exceptions in sequence, TimeSeriesViewer and SupplyPlan detect the fromExceptions flag from ExceptionContext and render an overlay header showing:
- Current position in the exception list (e.g. "Exception 3 of 47")
- Previous / Next navigation buttons
- A "Back to exceptions" link
The overlay uses prefetched data from ExceptionContext.getCached(itemId) to display summary data without an additional API call.
Pattern in a page component:
const { fromExceptions, position, list, setPosition } = useExceptions();
const { activePipelineId } = usePipeline();
const goNext = () => {
if (position < list.length - 1) {
const next = list[position + 1];
setPosition(position + 1, activePipelineId);
navigate(getTargetPage(next));
}
};
{fromExceptions && (
<div className="exception-nav-overlay">
<span>{position + 1} of {list.length}</span>
<button onClick={goNext}>Next →</button>
</div>
)}
State Management Philosophy¶
Mirabelle uses React Context + local useState — no Redux, no Zustand, no MobX.
Why this works:
- The app is read-heavy — most pages fetch data and display it; they don't maintain complex cross-component state machines.
- Mutations (creating segments, running pipelines, locking methods) are isolated operations that update a local component state after the API call returns.
- The only genuinely shared state is:
- Authentication (
AuthContext) — needed by every page and the axios interceptor. - Active pipeline (
PipelineContext) — needed by every data-fetching call. - Exception navigation (
ExceptionContext) — needed by the overlay mechanism. - Everything else is local
useStatewithin the relevant component.
When to add a new context: Only when state needs to be shared between components that are not in a direct parent-child relationship and are too far apart to pass props efficiently. For everything else, keep it local.
Key Conventions Summary¶
| Convention | Rule |
|---|---|
| API calls | Always use import api from '../utils/api', never raw axios |
| Supply endpoints | Always use useSupplyParams(), never inline pipeline_id |
| Styling | Tailwind CSS only — no external CSS files |
| Dark mode | Use dark: Tailwind variants throughout |
| Charts | Plotly.js for interactive charts; inline SVG for sparklines |
| Method colours | Use METHOD_COLORS constant from TimeSeriesViewer.jsx |
| Best method | Highlight with emerald green (#059669 / bg-emerald-*) |
| Route split | All page components are lazy()-loaded |
| Request cleanup | Always use AbortController and return controller.abort() from useEffect |
Tour System (src/tour/)¶
The application has two guided tours built with Shepherd.js:
| Tour | Purpose | Trigger |
|---|---|---|
| Full App Tour | Walks through every page in the app, one at a time | "Full Tour" button in the sidebar |
| Demo Tour | 9-stop presales narrative focusing on management KPIs and planner simplicity | "Demo Tour" button in the sidebar |
Key files¶
| File | Purpose |
|---|---|
src/tour/tourSteps.js |
All step definitions, page-order arrays, and builder functions |
src/tour/useTour.js |
React hook (useTour) exposing startFullTour and startDemoTour |
src/tour/tourStyles.css |
Custom Shepherd CSS overrides (drag-to-move, positioning) |
How tours navigate cross-page¶
Shepherd runs inside a single React render tree. When a tour step navigates to a new URL, the hook's cleanup fires and would normally complete() the tour. To prevent this:
- Bridge steps set
isNavigatingRef.current = truebefore callingtour.complete()+navigate(). - The cleanup guard checks
isNavigatingRef.current— if true, it does not calltour.complete(). - A
useEffectonlocation.pathname(ordemoNavCounterfor same-path stops) reads session storage and re-launches the tour on the new page after a short delay.
Demo tour — 9 stops¶
| # | Page | Tab | What it shows |
|---|---|---|---|
| 1 | Dashboard / |
Overview | Left sidebar, top toolbar, right toolbar (Mirabelle panel), overview tabs, management KPIs |
| 2 | Pipelines /pipelines |
Pipeline | Pipeline & scenario concept, expand to see scenario stack |
| 3 | Dashboard / |
Forecast | Probabilistic & feature forecasting (causal + maintenance) |
| 4 | Series Detail /series/:id |
Forecast | Item-level drill: methods, outlier analysis, netting |
| 5 | Dashboard / |
EOQ | High-level EOQ KPIs, Wilson formula drill-in |
| 6 | Dashboard / |
IO | MEIO fill-rate heatmap, cell detail, supply path |
| 7 | Dashboard / |
Supply | Network replenishment, exceptions, projected inventory |
| 8 | Priorities /exceptions |
— | Exception workflow, inline overrides, nav arrows |
| 9 | Process Runner /processes |
— | Individual processes, workflows, cluster, conclusion |
Stops 1, 3, 5, 6, 7 all navigate to / but activate different Dashboard tabs. Same-path navigation is handled by incrementing demoNavCounter state (not just a ref) so React re-renders and the auto-continue useEffect fires.
Step action hooks¶
Each tour step definition supports these special properties:
| Property | What it does |
|---|---|
activateTab |
{ container: '#dash-tab-bar', text: 'IO' } — finds a button in the container whose text matches and clicks it. Retries up to 12 times at 400 ms intervals. |
_clickBefore |
{ selector: '#mirabelle-toggle-btn' } — clicks an element before the step renders. Used to open panels (e.g. Mirabelle right panel). |
_fillBefore |
{ selector: '#alerts-query-input', value: '…' } — fills an input with text using React-compatible native setter. |
_navigateTo |
Path string on the last step of each page. The button action: 'navigate-next' advances the page index and navigates. |
Pre-tour guards¶
startDemoTour() in useTour.js runs two guards before launching:
- Sidebar guard — if the sidebar is collapsed (
#sidebar-pipeline-wrapmissing), clicks#sidebar-toggle-btnto expand it. - Mirabelle panel guard — if the Mirabelle panel is already open (
#mirabelle-panel-bodypresent), clicks#mirabelle-toggle-btnto close it. This ensures that_clickBeforeon the "Right Toolbar" step will open the panel (not toggle it closed).
Both guards add a 250 ms delay for React to re-render before the first tour step appears.
DOM IDs used by the tour¶
Tour steps attach to elements via attachTo.element. The following IDs must exist in the DOM for the tour to work:
| ID | Component | Purpose |
|---|---|---|
#sidebar-nav |
Sidebar | Left navigation panel |
#sidebar-toggle-btn |
Sidebar | Collapse/expand toggle |
#sidebar-pipeline-wrap |
Sidebar | Pipeline selector container |
#series-toolbar-slot |
App.jsx | Top toolbar portal target |
#mirabelle-toggle-btn |
App.jsx | Mirabelle right-panel toggle button |
#mirabelle-panel-body |
MirabellePanel.jsx | Mirabelle panel container (only rendered when panel is open) |
#dash-tab-bar |
Dashboard.jsx | Dashboard sub-tab switcher |
#dash-summary |
Dashboard.jsx | KPI summary cards |
#dash-charts |
Dashboard.jsx | Analytics charts section |
#dash-table |
Dashboard.jsx | Series table |
#detail-tab-bar |
TimeSeriesViewer.jsx | Detail sub-tab switcher |
#tsv-selector |
TimeSeriesViewer.jsx | Item/site selector |
#tsv-ridge-panel |
TimeSeriesViewer.jsx | Ridge chart panel |
#tsv-netting-section |
TimeSeriesViewer.jsx | Netting tab content |
#tsv-npi-btn |
TimeSeriesViewer.jsx | NPI button |
#pipelines-tab-bar |
Pipelines.jsx | Pipelines sub-tab switcher |
#pipelines-content |
Pipelines.jsx | Pipelines tab content area |
#pipeline-full |
ProcessRunner.jsx | "Run All" button area |
#pipeline-steps |
ProcessRunner.jsx | Individual step cards |
#process-runner-tab-bar |
ProcessRunner.jsx | Process Runner sub-tab switcher |
#exc-filter-bar |
ExceptionWorkflow.jsx | Exception filter bar |
Dependencies Summary (package.json)¶
| Package | Purpose |
|---|---|
react / react-dom |
UI framework |
react-router-dom |
Client-side routing |
axios |
HTTP client |
plotly.js-dist-min |
Interactive charts |
react-plotly.js |
React wrapper for Plotly |
leaflet |
Map rendering (supply network overview) |
@azure/msal-browser |
Microsoft OAuth |
@react-oauth/google |
Google OAuth |
jwt-decode |
JWT parsing (for role checks without API call) |
shepherd.js |
Guided product tour |
@opentelemetry/* |
Browser tracing (optional, sends to Jaeger) |
tailwindcss |
Utility-first CSS |
vite |
Dev server and build tool |