Skip to content

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:

import api from '../utils/api';

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?

  1. 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.
  2. Centralised 401 handling: Session expiry is handled in one place instead of every catch block.
  3. 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.
  4. Token key consistency: The token key (mirabelle_token) and user key (mirabelle_user) are defined here and in AuthContext.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_id scopes the backend query to the correct tenant pipeline.
  • scenario_id (specifically supply_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:

  1. POST /api/sparklines returns compact [[date, value], ...] arrays for all visible series in one call.
  2. Each series' values are normalised to the SVG viewport height.
  3. 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 &rarr;</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 useState within 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:

  1. Bridge steps set isNavigatingRef.current = true before calling tour.complete() + navigate().
  2. The cleanup guard checks isNavigatingRef.current — if true, it does not call tour.complete().
  3. A useEffect on location.pathname (or demoNavCounter for 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:

  1. Sidebar guard — if the sidebar is collapsed (#sidebar-pipeline-wrap missing), clicks #sidebar-toggle-btn to expand it.
  2. Mirabelle panel guard — if the Mirabelle panel is already open (#mirabelle-panel-body present), clicks #mirabelle-toggle-btn to close it. This ensures that _clickBefore on 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