{ "cells": [ { "cell_type": "markdown", "id": "a79bef3a", "metadata": {}, "source": [ "# CTM × Genesys CCaaS — Migration + WFM (No-AI) Case\n", "\n", "**Thesis:** strip every AI capability out of the business case and price the platform move\n", "alone: migrate all four regions to Genesys Cloud CX, move NA off its existing WFM-like tool\n", "onto Genesys WFM, and implement WFM for APAC (**ANZ + ASIA**). Migration and WFM are\n", "**included in the base implementation price** — contracted as the signed NTT SOW: **$2.03M\n", "professional services** billed over four milestones (2026–27), plus the verbatim $167K\n", "training and **$411K/yr managed services** from MCX go-live (30 Sep 2026). The only\n", "benefits kept are the deck's verbatim **WFM** lines for the regions\n", "in WFM scope; every other benefit (Agent Copilot, Email, STA, Predictive Routing, Supervisor\n", "Copilot) is AI-driven and out.\n", "\n", "| Layer | Source | Treatment |\n", "|---|---|---|\n", "| WFM benefits | *Appendix 4 — CCaaS Platform Benefit Calculations (Consolidated).pptx*, slides 12–15 | **Verbatim**, scoped to NA/ANZ/ASIA, phased on Genesys's own deployment schedule |\n", "| CCaaS licences | Deck TCO (slides 5–6) + signed contract | Deck pitched $4.3M/yr; **contracted $3.2M/yr** drives the case · 6-month ramp credit (🟢 order form) |\n", "| NTT PS + training | Signed SOW + deck training line | **$2.03M over four billing milestones (2026–27)** + $167K training — **includes migration + WFM** (deck pitched $2.4M, year 1) |\n", "| Managed services | Signed SOW | **$411K/yr from MCX go-live (30 Sep 2026)** — ongoing run-rate cost the deck's TCO never carried |\n", "| Existing platform run-off | Term contracts to 31 Dec 2027, per-region | Double-billing until termination |\n", "| AI tokens / AI implementation | — | **Removed** — see `ctm_business_case_corrected.ipynb` |\n", "\n", "Companion to the Corrected Business Case notebook — same engine (`tokencalc`), same\n", "baseline-relative frame, same 2026–2028 window, so the two cases are directly comparable.\n", "Confidence legend: 🟢 confirmed (published/contractual) · 🟡 estimated (working assumption) · 🔴 unknown.\n", "\n", "*This notebook is the deliverable: serve it interactively with `mercury --working-dir notebooks/`, tune the 🟡 inputs live for the client, then export an LLM-readable report source with `python scripts/export_report.py`.*" ] }, { "cell_type": "code", "execution_count": 1, "id": "38d0b7d9", "metadata": { "execution": { "iopub.execute_input": "2026-07-08T15:15:47.299824Z", "iopub.status.busy": "2026-07-08T15:15:47.299589Z", "iopub.status.idle": "2026-07-08T15:15:47.712827Z", "shell.execute_reply": "2026-07-08T15:15:47.712043Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "tokencalc loaded — 9 sites, 2,088 named users (contracted: 2,088)\n" ] } ], "source": [ "# ── Setup ──────────────────────────────────────────────────────────\n", "import sys, pathlib\n", "_ROOT = pathlib.Path.cwd()\n", "if not (_ROOT / \"tokencalc\").exists(): # notebook lives in notebooks/\n", " _ROOT = _ROOT.parent\n", "sys.path.insert(0, str(_ROOT))\n", "\n", "import datetime as dt\n", "\n", "import pandas as pd\n", "import plotly.graph_objects as go\n", "\n", "import mercury as mr\n", "\n", "from tokencalc import *\n", "# Single source of truth — all math lives in the library; only\n", "# presentation (and Mercury input widgets) lives here.\n", "from tokencalc.appendix4 import (\n", " YEARS, YEAR_INDEX, REGIONS,\n", " VERBATIM_BENEFITS, TCO_VERBATIM, IMPL_MONTH, REALIZE_MONTH,\n", " DEFAULT_RAMP_MONTHS,\n", " build_rollouts, case_flows, case_kpis, crossfoot_tolerance,\n", " current_costs_by_year, current_state_inputs, licence_costs_by_year,\n", " MANAGED_SERVICES_ANNUAL, MCX_GO_LIVE, PS_CONTRACTED_TOTAL, PS_MILESTONES,\n", " managed_services_by_year, money, html_money, month_label, ps_costs_by_year,\n", " ps_milestones_dataframe, region_agents, region_site_names, tco,\n", ")\n", "from tokencalc.migration_wfm import (\n", " DEFAULT_WFM_REGIONS, runrate_breakeven_label, runrate_saving_annual,\n", " wfm_annual_runrate, wfm_benefits_by_year,\n", ")\n", "from tokencalc.business_case import npv\n", "from tokencalc.staging import backstage\n", "\n", "pd.options.display.float_format = \"{:,.0f}\".format\n", "\n", "# ── Chart chrome (dataviz reference palette, light surface) ─────────\n", "INK, INK2, MUTED = \"#0b0b0b\", \"#52514e\", \"#898781\"\n", "SURFACE, GRID, BASELINE = \"#fcfcfb\", \"#e1e0d9\", \"#c3c2b7\"\n", "CUMULATIVE, CONTEXT = \"#52514e\", \"#c3c2b7\" # neutral line; de-emphasized context series\n", "FONT_STACK = 'system-ui, -apple-system, \"Segoe UI\", sans-serif'\n", "\n", "# Fixed region colors — color follows the entity across every figure.\n", "REGION_COLOR = {\"NA\": \"#2a78d6\", \"ANZ\": \"#1baf7a\", \"EMEA\": \"#4a3aa7\", \"ASIA\": \"#eda100\"}\n", "COST_COLOR = {\n", " \"CCaaS platform licences (contracted, ramp-adjusted)\": \"#2a78d6\",\n", " \"NTT PS + training (SOW billing milestones)\": \"#1baf7a\",\n", " \"NTT managed services (from MCX go-live)\": \"#0b6b4a\",\n", " \"Existing platform (term-contract run-off)\": \"#eda100\",\n", "}\n", "DIVERGING = [[0.0, \"#e34948\"], [0.5, \"#f0efe9\"], [1.0, \"#2a78d6\"]]\n", "\n", "\n", "def tei_layout(fig, title, subtitle=None, height=460):\n", " t = f\"{title}\"\n", " if subtitle:\n", " t += f\"
{subtitle}\"\n", " fig.update_layout(\n", " title=dict(text=t, font=dict(size=16, color=INK), x=0.02, xanchor=\"left\"),\n", " paper_bgcolor=SURFACE, plot_bgcolor=SURFACE,\n", " font=dict(family=FONT_STACK, size=12, color=INK2),\n", " legend=dict(orientation=\"h\", yanchor=\"top\", y=-0.10, x=0,\n", " font=dict(size=11, color=INK2)),\n", " xaxis=dict(type=\"category\", showgrid=False, linecolor=BASELINE,\n", " tickfont=dict(color=MUTED)),\n", " yaxis=dict(gridcolor=GRID, zerolinecolor=BASELINE, zerolinewidth=1.5,\n", " tickformat=\"$~s\", tickfont=dict(color=MUTED)),\n", " hovermode=\"x unified\", bargap=0.45, height=height,\n", " margin=dict(t=70, r=30, b=80, l=70),\n", " )\n", " return fig\n", "\n", "\n", "def bar(x, y, name, color):\n", " return go.Bar(x=x, y=y, name=name,\n", " marker=dict(color=color, line=dict(width=2, color=SURFACE)),\n", " hovertemplate=\"%{fullData.name}: %{y:$,.0f}\")\n", "\n", "\n", "def cum_line(x, y, name, color=CUMULATIVE, dash=None):\n", " return go.Scatter(x=x, y=y, name=name, mode=\"lines+markers\",\n", " line=dict(color=color, width=2, dash=dash),\n", " marker=dict(size=8, line=dict(width=2, color=SURFACE)),\n", " hovertemplate=\"%{fullData.name}: %{y:$,.0f}\")\n", "\n", "\n", "X = [str(y) for y in YEARS]\n", "\n", "backstage(f\"tokencalc loaded — {len(CTM_DEFAULT_SITES)} sites, \"\n", " f\"{sum(s.named_users for s in CTM_DEFAULT_SITES):,} named users \"\n", " f\"(contracted: {CONTRACTED_NAMED_USERS:,})\")" ] }, { "cell_type": "code", "execution_count": 2, "id": "104afa86", "metadata": { "execution": { "iopub.execute_input": "2026-07-08T15:15:47.715016Z", "iopub.status.busy": "2026-07-08T15:15:47.714770Z", "iopub.status.idle": "2026-07-08T15:15:47.725088Z", "shell.execute_reply": "2026-07-08T15:15:47.724690Z" } }, "outputs": [ { "data": { "application/mercury+json": { "model_id": "cf504293e15c4cd99f1a492d73d8e263", "position": "sidebar", "widget": "MarkdownWidget" }, "application/vnd.jupyter.widget-view+json": { "model_id": "cf504293e15c4cd99f1a492d73d8e263", "version_major": 2, "version_minor": 0 }, "text/plain": [ "MarkdownWidget(value='
{label}'\n", " for n, label in _TOC)\n", "_toc = mr.Markdown(\n", " text=(f'Jump to section'\n", " f'
    {_items}
'),\n", " position=\"sidebar\")" ] }, { "cell_type": "markdown", "id": "cecdb4b9", "metadata": {}, "source": [ "\n", "\n", "## 1 · Current state & contract inputs — data collection\n", "\n", "Verbatim anchors (Appendix 4, slides 5–6): current solution costs **$7.3M/yr globally**\n", "(ANZ, APAC, EMEA, NA — $22M over 3 years); CCaaS is **$4.3M/yr** with **$2.4M professional\n", "services + $167K training in year 1**.\n", "\n", "The same three contract facts as the corrected notebook, collected as editable inputs:\n", "\n", "1. **Per-region current cost** — seeded as $7.3M × (region agents ÷ total agents). 🟡 Allocation\n", " estimate; replace with real regional contract values as they arrive. If NA's existing\n", " WFM-like tool bills separately from the platform, fold it into NA's number here — migrating\n", " off it is part of this scenario.\n", "2. **Contract termination dates** — the current platforms are **term contracts**; CTM pays until\n", " termination (default **31 Dec 2027**) regardless of when Genesys goes live → double-billing.\n", "3. **Genesys ramp programme** — `RAMP_MONTHS = 6` (🟢 order form): licences are free for\n", " the first 6 months, so the licence run-rate bills from July 2026." ] }, { "cell_type": "code", "execution_count": 3, "id": "f8863db7", "metadata": { "execution": { "iopub.execute_input": "2026-07-08T15:15:47.727202Z", "iopub.status.busy": "2026-07-08T15:15:47.727057Z", "iopub.status.idle": "2026-07-08T15:15:47.757766Z", "shell.execute_reply": "2026-07-08T15:15:47.757050Z" } }, "outputs": [ { "data": { "application/mercury+json": { "model_id": "ddacc3befdc54c278abccd4a5b6b98b5", "position": "inline", "widget": "NumberInputWidget" }, "application/vnd.jupyter.widget-view+json": { "model_id": "ddacc3befdc54c278abccd4a5b6b98b5", "version_major": 2, "version_minor": 1 }, "text/plain": [ "" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "application/mercury+json": { "model_id": "6d231d033d7b4f41b852046f069ebc3f", "position": "inline", "widget": "NumberInputWidget" }, "application/vnd.jupyter.widget-view+json": { "model_id": "6d231d033d7b4f41b852046f069ebc3f", "version_major": 2, "version_minor": 1 }, "text/plain": [ "" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "application/mercury+json": { "model_id": "b2ec5338e0ba4055b77a57edd08b464c", "position": "inline", "widget": "DateInputWidget" }, "application/vnd.jupyter.widget-view+json": { "model_id": "b2ec5338e0ba4055b77a57edd08b464c", "version_major": 2, "version_minor": 1 }, "text/plain": [ "" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "application/mercury+json": { "model_id": "28eb2b2d2a2a41adaad830ac204d7b39", "position": "inline", "widget": "NumberInputWidget" }, "application/vnd.jupyter.widget-view+json": { "model_id": "28eb2b2d2a2a41adaad830ac204d7b39", "version_major": 2, "version_minor": 1 }, "text/plain": [ "" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "application/mercury+json": { "model_id": "4d3b2b0c854b4bcab393ef4f89aee1b4", "position": "inline", "widget": "DateInputWidget" }, "application/vnd.jupyter.widget-view+json": { "model_id": "4d3b2b0c854b4bcab393ef4f89aee1b4", "version_major": 2, "version_minor": 1 }, "text/plain": [ "" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "application/mercury+json": { "model_id": "9a1300f4e5374fa6bb1c9717866f8ce1", "position": "inline", "widget": "NumberInputWidget" }, "application/vnd.jupyter.widget-view+json": { "model_id": "9a1300f4e5374fa6bb1c9717866f8ce1", "version_major": 2, "version_minor": 1 }, "text/plain": [ "" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "application/mercury+json": { "model_id": "8e89c81717ff408d9e5cb1a949ab337c", "position": "inline", "widget": "DateInputWidget" }, "application/vnd.jupyter.widget-view+json": { "model_id": "8e89c81717ff408d9e5cb1a949ab337c", "version_major": 2, "version_minor": 1 }, "text/plain": [ "" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "application/mercury+json": { "model_id": "cdf8fe1626a54e7eb048e9e8d27cb2a4", "position": "inline", "widget": "NumberInputWidget" }, "application/vnd.jupyter.widget-view+json": { "model_id": "cdf8fe1626a54e7eb048e9e8d27cb2a4", "version_major": 2, "version_minor": 1 }, "text/plain": [ "" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "application/mercury+json": { "model_id": "68b411c315ff4357815d7ff9801f445a", "position": "inline", "widget": "DateInputWidget" }, "application/vnd.jupyter.widget-view+json": { "model_id": "68b411c315ff4357815d7ff9801f445a", "version_major": 2, "version_minor": 1 }, "text/plain": [ "" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "# ── Contract inputs (model: tokencalc.appendix4) ─────────────────────\n", "# ⚙ Interactive when served with Mercury; headless runs (nbconvert /\n", "# regression gate) use the widget defaults below.\n", "# NB: Mercury re-executes only cells BELOW a changed widget's cell,\n", "# so this cell constructs widgets ONLY — .value is read downstream.\n", "_ramp_w = mr.NumberInput(\n", " label=\"Genesys ramp — licence-free months\", value=DEFAULT_RAMP_MONTHS,\n", " min=0, max=24, step=1, position=\"inline\")\n", "\n", "sites = list(CTM_DEFAULT_SITES)\n", "REGION_SITES = region_site_names(sites)\n", "agents_by_region = region_agents(sites)\n", "\n", "# ── Per-region current-state inputs (EDIT as real data arrives) ──────\n", "# Seeded as $7.3M × agent share; annual_cost and contract_termination\n", "# are the editable inputs.\n", "_seed = current_state_inputs(sites)\n", "assert abs(_seed[\"annual_cost\"].sum() - TCO_VERBATIM[\"current_annual\"]) < 1\n", "\n", "_cost_w, _term_w = {}, {}\n", "for _r in REGIONS:\n", " _cost_w[_r] = mr.NumberInput(\n", " label=f\"{_r} — current platform cost ($/yr)\",\n", " value=round(float(_seed.loc[_r, \"annual_cost\"])),\n", " min=0, max=8_000_000, step=10_000, position=\"inline\")\n", " _term_w[_r] = mr.DateInput(\n", " label=f\"{_r} — contract termination\",\n", " value=_seed.loc[_r, \"contract_termination\"].isoformat(),\n", " position=\"inline\")" ] }, { "cell_type": "code", "execution_count": 4, "id": "0bf61187", "metadata": { "execution": { "iopub.execute_input": "2026-07-08T15:15:47.759694Z", "iopub.status.busy": "2026-07-08T15:15:47.759486Z", "iopub.status.idle": "2026-07-08T15:15:47.771664Z", "shell.execute_reply": "2026-07-08T15:15:47.771155Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "current-state total $7.3M/yr (seed: $7.3M; deck 3-yr $22.0M — deck rounding)\n", "existing-platform run-off by year: {2026: '$7.3M', 2027: '$7.3M', 2028: '$0K'}\n" ] }, { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
agentsshareannual_costcontract_terminationconfidence
region
NA89003,348,9692027-12-31🟡 agent-share allocation of the verbatim total
ANZ1800677,3202027-12-31🟡 agent-share allocation of the verbatim total
EMEA32001,204,1242027-12-31🟡 agent-share allocation of the verbatim total
ASIA55002,069,5882027-12-31🟡 agent-share allocation of the verbatim total
\n", "
" ], "text/plain": [ " agents share annual_cost contract_termination \\\n", "region \n", "NA 890 0 3,348,969 2027-12-31 \n", "ANZ 180 0 677,320 2027-12-31 \n", "EMEA 320 0 1,204,124 2027-12-31 \n", "ASIA 550 0 2,069,588 2027-12-31 \n", "\n", " confidence \n", "region \n", "NA 🟡 agent-share allocation of the verbatim total \n", "ANZ 🟡 agent-share allocation of the verbatim total \n", "EMEA 🟡 agent-share allocation of the verbatim total \n", "ASIA 🟡 agent-share allocation of the verbatim total " ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "# ── Contract values → model (re-runs on any change to the widgets above) ─\n", "RAMP_MONTHS = int(_ramp_w.value)\n", "\n", "current_state = _seed.copy()\n", "for _r in REGIONS:\n", " current_state.loc[_r, \"annual_cost\"] = float(_cost_w[_r].value)\n", " current_state.loc[_r, \"contract_termination\"] = dt.date.fromisoformat(\n", " _term_w[_r].value)\n", "\n", "current_by_year = current_costs_by_year(current_state)\n", "\n", "backstage(f\"current-state total {money(current_state['annual_cost'].sum())}/yr \"\n", " f\"(seed: {money(TCO_VERBATIM['current_annual'])}; deck 3-yr \"\n", " f\"{money(TCO_VERBATIM['current_3yr'])} — deck rounding)\")\n", "backstage(f\"existing-platform run-off by year: \"\n", " f\"{ {y: money(v) for y, v in current_by_year.items()} }\")\n", "display(current_state)" ] }, { "cell_type": "markdown", "id": "2caa14c2", "metadata": {}, "source": [ "\n", "\n", "## 2 · Scenario scope — what the base price includes\n", "\n", "- **Genesys Cloud CX migration** for all four regions.\n", "- **NA — WFM migration**: users move off the existing WFM-like tool onto Genesys WFM. The deck\n", " claims **$0** WFM benefit for NA (\"has similar feature\") — kept verbatim.\n", "- **APAC (ANZ + ASIA) — WFM implementation**: net-new WFM capability.\n", "- **EMEA — no WFM** in this scenario; the deck's $687K 3-yr EMEA WFM line is dropped\n", " (upside option — toggle it in the sidebar to see the effect).\n", "- **Base implementation price covers all of the above** — contracted as the signed NTT SOW:\n", " **$2.03M professional services** billed over four milestones (2026–27; section 4 shows the\n", " schedule) plus the verbatim $167K training. No extra cost lines for migration or WFM.\n", "- 🟢 **Managed services — contracted**: **$410,918/yr** commences billing at MCX go-live\n", " (30 Sep 2026) — an ongoing run-rate cost the deck's TCO never carried; it stays in the\n", " post-window steady-state run-rate.\n", "- 🟢 **Licence run-rate — contracted**: the signed rate is **$3.2M/yr**, below the deck's\n", " pitched $4.3M/yr. The case runs on the contracted rate; the KPI table in section 5 keeps\n", " a deck-rate column beside it so the walk from the Genesys report stays visible.\n", " 🟡 Still assumed to include the WFM/WEM SKUs (incl. new APAC seats) — if WEM is an\n", " add-on, raise the sidebar rate." ] }, { "cell_type": "code", "execution_count": 5, "id": "8bafeb91", "metadata": { "execution": { "iopub.execute_input": "2026-07-08T15:15:47.773242Z", "iopub.status.busy": "2026-07-08T15:15:47.773111Z", "iopub.status.idle": "2026-07-08T15:15:47.784646Z", "shell.execute_reply": "2026-07-08T15:15:47.784118Z" } }, "outputs": [ { "data": { "application/mercury+json": { "model_id": "cc5b28489de346d3b59e3ace05fb2ebb", "position": "sidebar", "widget": "NumberInputWidget" }, "application/vnd.jupyter.widget-view+json": { "model_id": "cc5b28489de346d3b59e3ace05fb2ebb", "version_major": 2, "version_minor": 1 }, "text/plain": [ "" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "application/mercury+json": { "model_id": "2be8dde4d1644691add8dae88a36ac81", "position": "sidebar", "widget": "CheckboxWidget" }, "application/vnd.jupyter.widget-view+json": { "model_id": "2be8dde4d1644691add8dae88a36ac81", "version_major": 2, "version_minor": 1 }, "text/plain": [ "" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "application/mercury+json": { "model_id": "cddbd4fd3deb4ed1a29dad5cf082939b", "position": "sidebar", "widget": "CheckboxWidget" }, "application/vnd.jupyter.widget-view+json": { "model_id": "cddbd4fd3deb4ed1a29dad5cf082939b", "version_major": 2, "version_minor": 1 }, "text/plain": [ "" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "application/mercury+json": { "model_id": "1d4ddc81f2c249cf9be44e32e8213b85", "position": "sidebar", "widget": "CheckboxWidget" }, "application/vnd.jupyter.widget-view+json": { "model_id": "1d4ddc81f2c249cf9be44e32e8213b85", "version_major": 2, "version_minor": 1 }, "text/plain": [ "" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "application/mercury+json": { "model_id": "5c47680e253c4f45bbbbfcc76612499b", "position": "sidebar", "widget": "CheckboxWidget" }, "application/vnd.jupyter.widget-view+json": { "model_id": "5c47680e253c4f45bbbbfcc76612499b", "version_major": 2, "version_minor": 1 }, "text/plain": [ "" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "# ── CONFIG — scenario scope (Mercury sidebar — widgets only, no output) ─\n", "# NB: Mercury re-executes only cells BELOW a changed widget's cell,\n", "# so this cell constructs widgets ONLY — .value is read downstream.\n", "_licence_w = mr.NumberInput(\n", " label=\"Genesys licence run-rate ($/yr)\",\n", " value=round(tco(\"ccaas_annual\")), # contracted; deck pitched $4.3M\n", " min=0, max=10_000_000, step=100_000)\n", "_wfm_w = {\n", " \"NA\": mr.CheckBox(label=\"WFM — NA (migrate existing users)\", value=True),\n", " \"ANZ\": mr.CheckBox(label=\"WFM — ANZ (implement)\", value=True),\n", " \"ASIA\": mr.CheckBox(label=\"WFM — ASIA (implement)\", value=True),\n", " \"EMEA\": mr.CheckBox(label=\"WFM — EMEA (upside option)\", value=False),\n", "}" ] }, { "cell_type": "code", "execution_count": 6, "id": "6d971716", "metadata": { "execution": { "iopub.execute_input": "2026-07-08T15:15:47.786019Z", "iopub.status.busy": "2026-07-08T15:15:47.785869Z", "iopub.status.idle": "2026-07-08T15:15:47.802381Z", "shell.execute_reply": "2026-07-08T15:15:47.801425Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "WFM scope: NA, ANZ, ASIA · licence run-rate $3.2M/yr contracted (deck pitched $4.3M/yr) · ramp 6 months\n", "ramp-adjusted licences by year: {2026: '$1.6M', 2027: '$3.2M', 2028: '$3.2M'}\n", "NTT PS + training (SOW milestones, incl. migration + WFM): $2.2M across 2026-27 · managed services $411K/yr from MCX go-live\n" ] }, { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
WFM treatmentin scopeverbatim annualverbatim 3-yr
region
NAmigrate existing users00
ANZimplement (APAC)13000001400000
EMEAimplement (upside option)824000687000
ASIAimplement (APAC)1600000914000
\n", "
" ], "text/plain": [ " WFM treatment in scope verbatim annual verbatim 3-yr\n", "region \n", "NA migrate existing users ✓ 0 0\n", "ANZ implement (APAC) ✓ 1300000 1400000\n", "EMEA implement (upside option) — 824000 687000\n", "ASIA implement (APAC) ✓ 1600000 914000" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "# ── Scope derivation + licence schedule ──────────────────────────────\n", "LICENCE_ANNUAL = float(_licence_w.value)\n", "WFM_REGIONS = [r for r in REGIONS if bool(_wfm_w[r].value)]\n", "\n", "licence_by_year = licence_costs_by_year(RAMP_MONTHS, LICENCE_ANNUAL)\n", "ps_by_year = ps_costs_by_year(contracted=True) # SOW milestones + verbatim training\n", "managed_by_year = managed_services_by_year() # 🟢 bills from MCX go-live (30 Sep 2026)\n", "\n", "_treat = {\"NA\": \"migrate existing users\", \"ANZ\": \"implement (APAC)\",\n", " \"ASIA\": \"implement (APAC)\", \"EMEA\": \"implement (upside option)\"}\n", "wfm_scope_table = pd.DataFrame([\n", " {\"region\": r, \"WFM treatment\": _treat[r],\n", " \"in scope\": \"✓\" if r in WFM_REGIONS else \"—\",\n", " \"verbatim annual\": VERBATIM_BENEFITS[(r, \"WFM\")][0],\n", " \"verbatim 3-yr\": VERBATIM_BENEFITS[(r, \"WFM\")][1]}\n", " for r in REGIONS]).set_index(\"region\")\n", "\n", "print(f\"WFM scope: {', '.join(WFM_REGIONS) or '(none)'} · \"\n", " f\"licence run-rate {money(LICENCE_ANNUAL)}/yr contracted \"\n", " f\"(deck pitched {money(TCO_VERBATIM['ccaas_annual'])}/yr) · ramp {RAMP_MONTHS} months\")\n", "backstage(f\"ramp-adjusted licences by year: \"\n", " f\"{ {y: money(v) for y, v in licence_by_year.items()} }\")\n", "print(f\"NTT PS + training (SOW milestones, incl. migration + WFM): \"\n", " f\"{money(sum(ps_by_year.values()))} across 2026-27 · managed services \"\n", " f\"{money(MANAGED_SERVICES_ANNUAL)}/yr from MCX go-live\")\n", "display(wfm_scope_table)" ] }, { "cell_type": "markdown", "id": "95b71218", "metadata": {}, "source": [ "\n", "\n", "## 3 · WFM benefits — verbatim, scoped, schedule-phased\n", "\n", "Only the deck's WFM lines survive the no-AI cut, and only for the regions in WFM scope.\n", "They phase on the same Genesys deployment schedule as the corrected notebook (benefits\n", "realize **implementation + 3 months**, inclusive): ANZ realizes Dec 2027, ASIA Jun 2028 —\n", "so most of the WFM value lands at the very edge of the 3-year window.\n", "\n", "*Deck quirk, kept verbatim: ASIA's WFM **annual** value ($1.6M) exceeds its **3-yr** value\n", "($914K) — the deck's own late-realization arithmetic. The 3-yr figure is what phases here;\n", "the annual figure feeds the post-window run-rate in section 5.*" ] }, { "cell_type": "code", "execution_count": 7, "id": "12673034", "metadata": { "execution": { "iopub.execute_input": "2026-07-08T15:15:47.804434Z", "iopub.status.busy": "2026-07-08T15:15:47.804197Z", "iopub.status.idle": "2026-07-08T15:15:47.852870Z", "shell.execute_reply": "2026-07-08T15:15:47.852057Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "WFM benefits by year: {2026: '$0K', 2027: '$108K', 2028: '$2.2M'} → 3-yr $2.3M\n" ] }, { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
year202620272028TOTAL
region
NA0000
ANZ0107,6921,292,3081,400,000
ASIA00914,000914,000
TOTAL0107,6922,206,3082,314,000
\n", "
" ], "text/plain": [ "year 2026 2027 2028 TOTAL\n", "region \n", "NA 0 0 0 0\n", "ANZ 0 107,692 1,292,308 1,400,000\n", "ASIA 0 0 914,000 914,000\n", "TOTAL 0 107,692 2,206,308 2,314,000" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "# ── WFM slice of the verbatim benefits (tokencalc.migration_wfm) ─────\n", "_, _, BENEFIT_ROLLOUT = build_rollouts(sites, ramp_months=RAMP_MONTHS)\n", "wfm_long = wfm_benefits_by_year(BENEFIT_ROLLOUT, WFM_REGIONS)\n", "ben_by_year = {y: float(wfm_long.loc[wfm_long.year == y, \"benefit\"].sum())\n", " for y in YEARS}\n", "\n", "backstage(f\"WFM benefits by year: { {y: money(v) for y, v in ben_by_year.items()} } \"\n", " f\"→ 3-yr {money(sum(ben_by_year.values()))}\")\n", "if wfm_long.empty:\n", " print(\"No WFM regions in scope — pure licence-swap case.\")\n", "else:\n", " display(wfm_long.pivot_table(index=\"region\", columns=\"year\", values=\"benefit\",\n", " aggfunc=\"sum\", margins=True, margins_name=\"TOTAL\")\n", " .reindex([*[r for r in REGIONS if r in WFM_REGIONS], \"TOTAL\"]))" ] }, { "cell_type": "code", "execution_count": 8, "id": "3cbefa13", "metadata": { "execution": { "iopub.execute_input": "2026-07-08T15:15:47.854742Z", "iopub.status.busy": "2026-07-08T15:15:47.854559Z", "iopub.status.idle": "2026-07-08T15:15:48.983809Z", "shell.execute_reply": "2026-07-08T15:15:48.980233Z" } }, "outputs": [ { "data": { "application/vnd.plotly.v1+json": { "config": { "plotlyServerURL": "https://plot.ly" }, "data": [ { "hovertemplate": "%{fullData.name}: %{y:$,.0f}", "marker": { "color": "#2a78d6", "line": { "color": "#fcfcfb", "width": 2 } }, "name": "NA WFM", "type": "bar", "x": [ "2026", "2027", "2028" ], "y": [ 0.0, 0.0, 0.0 ] }, { "hovertemplate": "%{fullData.name}: %{y:$,.0f}", "marker": { "color": "#1baf7a", "line": { "color": "#fcfcfb", "width": 2 } }, "name": "ANZ WFM", "type": "bar", "x": [ "2026", "2027", "2028" ], "y": [ 0.0, 107692.30769230769, 1292307.6923076923 ] }, { "hovertemplate": "%{fullData.name}: %{y:$,.0f}", "marker": { "color": "#eda100", "line": { "color": "#fcfcfb", "width": 2 } }, "name": "ASIA WFM", "type": "bar", "x": [ "2026", "2027", "2028" ], "y": [ 0.0, 0.0, 914000.0 ] }, { "hovertemplate": "%{fullData.name}: %{y:$,.0f}", "line": { "color": "#52514e", "width": 2 }, "marker": { "line": { "color": "#fcfcfb", "width": 2 }, "size": 8 }, "mode": "lines+markers", "name": "Cumulative WFM benefits", "type": "scatter", "x": [ "2026", "2027", "2028" ], "y": { "bdata": "AAAAAAAAAADsxE7sxEr6QP////+Hp0FB", "dtype": "f8" } } ], "layout": { "annotations": [ { "font": { "color": "#0b0b0b", "size": 12 }, "showarrow": false, "text": "$0K", "x": 0, "y": 0.0, "yshift": 12 }, { "font": { "color": "#0b0b0b", "size": 12 }, "showarrow": false, "text": "$108K", "x": 1, "y": 107692.30769230769, "yshift": 12 }, { "font": { "color": "#0b0b0b", "size": 12 }, "showarrow": false, "text": "$2.2M", "x": 2, "y": 2206307.692307692, "yshift": 12 } ], "bargap": 0.45, "barmode": "stack", "font": { "color": "#52514e", "family": "system-ui, -apple-system, \"Segoe UI\", sans-serif", "size": 12 }, "height": 420, "hovermode": "x unified", "legend": { "font": { "color": "#52514e", "size": 11 }, "orientation": "h", "x": 0, "y": -0.1, "yanchor": "top" }, "margin": { "b": 80, "l": 70, "r": 30, "t": 70 }, "paper_bgcolor": "#fcfcfb", "plot_bgcolor": "#fcfcfb", "template": { "data": { "bar": [ { "error_x": { "color": "#2a3f5f" }, "error_y": { "color": "#2a3f5f" }, "marker": { "line": { "color": "#E5ECF6", "width": 0.5 }, "pattern": { "fillmode": "overlay", "size": 10, "solidity": 0.2 } }, "type": "bar" } ], "barpolar": [ { "marker": { "line": { "color": "#E5ECF6", "width": 0.5 }, "pattern": { "fillmode": "overlay", "size": 10, "solidity": 0.2 } }, "type": "barpolar" } ], "carpet": [ { "aaxis": { "endlinecolor": "#2a3f5f", "gridcolor": "white", "linecolor": "white", "minorgridcolor": "white", "startlinecolor": "#2a3f5f" }, "baxis": { "endlinecolor": "#2a3f5f", "gridcolor": "white", "linecolor": "white", "minorgridcolor": "white", "startlinecolor": "#2a3f5f" }, "type": "carpet" } ], "choropleth": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "type": "choropleth" } ], "contour": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0.0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1.0, "#f0f921" ] ], "type": "contour" } ], "contourcarpet": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "type": "contourcarpet" } ], "heatmap": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0.0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1.0, "#f0f921" ] ], "type": "heatmap" } ], "histogram": [ { "marker": { "pattern": { "fillmode": "overlay", "size": 10, "solidity": 0.2 } }, "type": "histogram" } ], "histogram2d": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0.0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1.0, "#f0f921" ] ], "type": "histogram2d" } ], "histogram2dcontour": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0.0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1.0, "#f0f921" ] ], "type": "histogram2dcontour" } ], "mesh3d": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "type": "mesh3d" } ], "parcoords": [ { "line": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "parcoords" } ], "pie": [ { "automargin": true, "type": "pie" } ], "scatter": [ { "fillpattern": { "fillmode": "overlay", "size": 10, "solidity": 0.2 }, "type": "scatter" } ], "scatter3d": [ { "line": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scatter3d" } ], "scattercarpet": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scattercarpet" } ], "scattergeo": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scattergeo" } ], "scattergl": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scattergl" } ], "scattermap": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scattermap" } ], "scattermapbox": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scattermapbox" } ], "scatterpolar": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scatterpolar" } ], "scatterpolargl": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scatterpolargl" } ], "scatterternary": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scatterternary" } ], "surface": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0.0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1.0, "#f0f921" ] ], "type": "surface" } ], "table": [ { "cells": { "fill": { "color": "#EBF0F8" }, "line": { "color": "white" } }, "header": { "fill": { "color": "#C8D4E3" }, "line": { "color": "white" } }, "type": "table" } ] }, "layout": { "annotationdefaults": { "arrowcolor": "#2a3f5f", "arrowhead": 0, "arrowwidth": 1 }, "autotypenumbers": "strict", "coloraxis": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "colorscale": { "diverging": [ [ 0, "#8e0152" ], [ 0.1, "#c51b7d" ], [ 0.2, "#de77ae" ], [ 0.3, "#f1b6da" ], [ 0.4, "#fde0ef" ], [ 0.5, "#f7f7f7" ], [ 0.6, "#e6f5d0" ], [ 0.7, "#b8e186" ], [ 0.8, "#7fbc41" ], [ 0.9, "#4d9221" ], [ 1, "#276419" ] ], "sequential": [ [ 0.0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1.0, "#f0f921" ] ], "sequentialminus": [ [ 0.0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1.0, "#f0f921" ] ] }, "colorway": [ "#636efa", "#EF553B", "#00cc96", "#ab63fa", "#FFA15A", "#19d3f3", "#FF6692", "#B6E880", "#FF97FF", "#FECB52" ], "font": { "color": "#2a3f5f" }, "geo": { "bgcolor": "white", "lakecolor": "white", "landcolor": "#E5ECF6", "showlakes": true, "showland": true, "subunitcolor": "white" }, "hoverlabel": { "align": "left" }, "hovermode": "closest", "mapbox": { "style": "light" }, "paper_bgcolor": "white", "plot_bgcolor": "#E5ECF6", "polar": { "angularaxis": { "gridcolor": "white", "linecolor": "white", "ticks": "" }, "bgcolor": "#E5ECF6", "radialaxis": { "gridcolor": "white", "linecolor": "white", "ticks": "" } }, "scene": { "xaxis": { "backgroundcolor": "#E5ECF6", "gridcolor": "white", "gridwidth": 2, "linecolor": "white", "showbackground": true, "ticks": "", "zerolinecolor": "white" }, "yaxis": { "backgroundcolor": "#E5ECF6", "gridcolor": "white", "gridwidth": 2, "linecolor": "white", "showbackground": true, "ticks": "", "zerolinecolor": "white" }, "zaxis": { "backgroundcolor": "#E5ECF6", "gridcolor": "white", "gridwidth": 2, "linecolor": "white", "showbackground": true, "ticks": "", "zerolinecolor": "white" } }, "shapedefaults": { "line": { "color": "#2a3f5f" } }, "ternary": { "aaxis": { "gridcolor": "white", "linecolor": "white", "ticks": "" }, "baxis": { "gridcolor": "white", "linecolor": "white", "ticks": "" }, "bgcolor": "#E5ECF6", "caxis": { "gridcolor": "white", "linecolor": "white", "ticks": "" } }, "title": { "x": 0.05 }, "xaxis": { "automargin": true, "gridcolor": "white", "linecolor": "white", "ticks": "", "title": { "standoff": 15 }, "zerolinecolor": "white", "zerolinewidth": 2 }, "yaxis": { "automargin": true, "gridcolor": "white", "linecolor": "white", "ticks": "", "title": { "standoff": 15 }, "zerolinecolor": "white", "zerolinewidth": 2 } } }, "title": { "font": { "color": "#0b0b0b", "size": 16 }, "text": "WFM benefits — verbatim, scoped, schedule-phased
NA claims $0 (migration — deck: 'has similar feature') · ANZ realizes Dec 2027, ASIA Jun 2028 — value lands at the edge of the window", "x": 0.02, "xanchor": "left" }, "xaxis": { "linecolor": "#c3c2b7", "showgrid": false, "tickfont": { "color": "#898781" }, "type": "category" }, "yaxis": { "gridcolor": "#e1e0d9", "tickfont": { "color": "#898781" }, "tickformat": "$~s", "zerolinecolor": "#c3c2b7", "zerolinewidth": 1.5 } } } }, "metadata": {}, "output_type": "display_data" } ], "source": [ "fig = go.Figure()\n", "for _r in [r for r in REGIONS if r in WFM_REGIONS]:\n", " _v = [float(wfm_long.query(\"region == @_r and year == @y\")[\"benefit\"].sum())\n", " for y in YEARS]\n", " fig.add_trace(bar(X, _v, f\"{_r} WFM\", REGION_COLOR[_r]))\n", "cum_ben = pd.Series([ben_by_year[y] for y in YEARS]).cumsum()\n", "fig.add_trace(cum_line(X, cum_ben, \"Cumulative WFM benefits\"))\n", "for i, y in enumerate(YEARS):\n", " fig.add_annotation(x=i, y=ben_by_year[y], text=f\"{money(ben_by_year[y])}\",\n", " showarrow=False, yshift=12, font=dict(size=12, color=INK))\n", "fig.update_layout(barmode=\"stack\")\n", "tei_layout(fig, \"WFM benefits — verbatim, scoped, schedule-phased\",\n", " \"NA claims $0 (migration — deck: 'has similar feature') · ANZ realizes \"\n", " \"Dec 2027, ASIA Jun 2028 — value lands at the edge of the window\", height=420)\n", "fig.show()" ] }, { "cell_type": "markdown", "id": "059bd0f9", "metadata": {}, "source": [ "\n", "\n", "## 4 · Cost stack — licences, NTT SOW, double-billing\n", "\n", "Four lines. The AI token-consumption and AI implementation lines are gone —\n", "that is the point of this scenario. What remains is the deck's own cost case plus the\n", "contract facts it ignored: the **ramp credit** (licences bill from month 7 — 🟢 6-month\n", "ramp per the order form), the **term-contract run-off** (double-billing until\n", "31 Dec 2027), and the signed NTT SOW — **PS billed over four milestones** (50/50 across\n", "2026–27) and **managed services** from MCX go-live.\n" ] }, { "cell_type": "code", "execution_count": 9, "id": "ccde902f", "metadata": { "execution": { "iopub.execute_input": "2026-07-08T15:15:48.986149Z", "iopub.status.busy": "2026-07-08T15:15:48.985913Z", "iopub.status.idle": "2026-07-08T15:15:49.012029Z", "shell.execute_reply": "2026-07-08T15:15:49.008783Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Migration programme cost: {2026: '$10.2M', 2027: '$11.9M', 2028: '$3.6M'} → 3-yr $25.7M\n", "Do nothing: $7.3M/yr → 3-yr $21.9M\n" ] }, { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
2026202720283-yr
CCaaS platform licences (contracted, ramp-adjusted)1,600,0003,200,0003,200,0008,000,000
NTT PS + training (SOW billing milestones)1,179,7231,012,72302,192,446
NTT managed services (from MCX go-live)102,730410,918410,918924,566
Existing platform (term-contract run-off)7,300,0017,300,001014,600,002
TOTAL10,182,45411,923,6433,610,91825,717,015
\n", "
" ], "text/plain": [ " 2026 2027 \\\n", "CCaaS platform licences (contracted, ramp-adjus... 1,600,000 3,200,000 \n", "NTT PS + training (SOW billing milestones) 1,179,723 1,012,723 \n", "NTT managed services (from MCX go-live) 102,730 410,918 \n", "Existing platform (term-contract run-off) 7,300,001 7,300,001 \n", "TOTAL 10,182,454 11,923,643 \n", "\n", " 2028 3-yr \n", "CCaaS platform licences (contracted, ramp-adjus... 3,200,000 8,000,000 \n", "NTT PS + training (SOW billing milestones) 0 2,192,446 \n", "NTT managed services (from MCX go-live) 410,918 924,566 \n", "Existing platform (term-contract run-off) 0 14,600,002 \n", "TOTAL 3,610,918 25,717,015 " ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
dateshareamount
SOW billing milestone
SOW Effective Date2026-03-1530%$607,633.94
Start of client UAT (first region)2026-09-3020%$405,089.30
Start of client UAT (last region)2027-06-3030%$607,633.94
Completion of last go-live migration2027-09-3020%$405,089.30
\n", "
" ], "text/plain": [ " date share amount\n", "SOW billing milestone \n", "SOW Effective Date 2026-03-15 30% $607,633.94\n", "Start of client UAT (first region) 2026-09-30 20% $405,089.30\n", "Start of client UAT (last region) 2027-06-30 30% $607,633.94\n", "Completion of last go-live migration 2027-09-30 20% $405,089.30" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "migration_costs = pd.DataFrame({\n", " \"CCaaS platform licences (contracted, ramp-adjusted)\": licence_by_year,\n", " \"NTT PS + training (SOW billing milestones)\": ps_by_year,\n", " \"NTT managed services (from MCX go-live)\": managed_by_year,\n", " \"Existing platform (term-contract run-off)\": current_by_year,\n", "}).T[YEARS]\n", "migration_costs[\"3-yr\"] = migration_costs.sum(axis=1)\n", "total_by_year = {y: float(migration_costs[y].sum()) for y in YEARS}\n", "\n", "BASELINE_ANNUAL = TCO_VERBATIM[\"current_annual\"]\n", "donothing_by_year = {y: BASELINE_ANNUAL for y in YEARS}\n", "\n", "backstage(f\"Migration programme cost: { {y: money(v) for y, v in total_by_year.items()} } \"\n", " f\"→ 3-yr {money(sum(total_by_year.values()))}\")\n", "backstage(f\"Do nothing: {money(BASELINE_ANNUAL)}/yr → 3-yr {money(3 * BASELINE_ANNUAL)}\")\n", "display(pd.concat([migration_costs,\n", " pd.DataFrame(migration_costs.sum()).T.set_axis([\"TOTAL\"])]))\n", "\n", "_ms = ps_milestones_dataframe()\n", "_ms[\"amount\"] = _ms[\"amount\"].map(\"${:,.2f}\".format)\n", "_ms[\"share\"] = _ms[\"share\"].map(\"{:.0%}\".format)\n", "display(_ms.set_index(\"name\").rename_axis(\"SOW billing milestone\"))\n" ] }, { "cell_type": "code", "execution_count": 10, "id": "c058e795", "metadata": { "execution": { "iopub.execute_input": "2026-07-08T15:15:49.014884Z", "iopub.status.busy": "2026-07-08T15:15:49.014409Z", "iopub.status.idle": "2026-07-08T15:15:49.061734Z", "shell.execute_reply": "2026-07-08T15:15:49.057052Z" } }, "outputs": [ { "data": { "application/vnd.plotly.v1+json": { "config": { "plotlyServerURL": "https://plot.ly" }, "data": [ { "hovertemplate": "%{fullData.name}: %{y:$,.0f}", "marker": { "color": "#2a78d6", "line": { "color": "#fcfcfb", "width": 2 } }, "name": "CCaaS platform licences (contracted, ramp-adjusted)", "type": "bar", "x": [ "2026", "2027", "2028" ], "y": [ 1600000.0, 3200000.0, 3200000.0 ] }, { "hovertemplate": "%{fullData.name}: %{y:$,.0f}", "marker": { "color": "#1baf7a", "line": { "color": "#fcfcfb", "width": 2 } }, "name": "NTT PS + training (SOW billing milestones)", "type": "bar", "x": [ "2026", "2027", "2028" ], "y": [ 1179723.24, 1012723.24, 0.0 ] }, { "hovertemplate": "%{fullData.name}: %{y:$,.0f}", "marker": { "color": "#0b6b4a", "line": { "color": "#fcfcfb", "width": 2 } }, "name": "NTT managed services (from MCX go-live)", "type": "bar", "x": [ "2026", "2027", "2028" ], "y": [ 102729.60000000002, 410918.4000000001, 410918.4000000001 ] }, { "hovertemplate": "%{fullData.name}: %{y:$,.0f}", "marker": { "color": "#eda100", "line": { "color": "#fcfcfb", "width": 2 } }, "name": "Existing platform (term-contract run-off)", "type": "bar", "x": [ "2026", "2027", "2028" ], "y": [ 7300001.0, 7300001.0, 0.0 ] }, { "hovertemplate": "%{fullData.name}: %{y:$,.0f}", "line": { "color": "#52514e", "width": 2 }, "marker": { "line": { "color": "#fcfcfb", "width": 2 }, "size": 8 }, "mode": "lines+markers", "name": "Cumulative — migration programme", "type": "scatter", "x": [ "2026", "2027", "2028" ], "y": { "bdata": "rkfhuuZrY0F7FK4H/xR1QeF6FG6RhnhB", "dtype": "f8" } }, { "hovertemplate": "%{fullData.name}: %{y:$,.0f}", "line": { "color": "#c3c2b7", "dash": "dash", "width": 2 }, "marker": { "line": { "color": "#fcfcfb", "width": 2 }, "size": 8 }, "mode": "lines+markers", "name": "Cumulative — do nothing", "type": "scatter", "x": [ "2026", "2027", "2028" ], "y": { "bdata": "oGNvAEDH3gDgKk4B", "dtype": "i4" } } ], "layout": { "annotations": [ { "font": { "color": "#0b0b0b", "size": 12 }, "showarrow": false, "text": "$10.2M", "x": 0, "y": 10182453.84, "yshift": 12 }, { "font": { "color": "#0b0b0b", "size": 12 }, "showarrow": false, "text": "$11.9M", "x": 1, "y": 11923642.64, "yshift": 12 }, { "font": { "color": "#0b0b0b", "size": 12 }, "showarrow": false, "text": "$3.6M", "x": 2, "y": 3610918.4, "yshift": 12 }, { "font": { "color": "#52514e", "size": 12 }, "showarrow": false, "text": "3-yr $25.7M — $3.8M above doing nothing", "x": 2, "xshift": -70, "y": 25717014.88, "yshift": 18 } ], "bargap": 0.45, "barmode": "stack", "font": { "color": "#52514e", "family": "system-ui, -apple-system, \"Segoe UI\", sans-serif", "size": 12 }, "height": 500, "hovermode": "x unified", "legend": { "font": { "color": "#52514e", "size": 11 }, "orientation": "h", "x": 0, "y": -0.1, "yanchor": "top" }, "margin": { "b": 80, "l": 70, "r": 30, "t": 70 }, "paper_bgcolor": "#fcfcfb", "plot_bgcolor": "#fcfcfb", "template": { "data": { "bar": [ { "error_x": { "color": "#2a3f5f" }, "error_y": { "color": "#2a3f5f" }, "marker": { "line": { "color": "#E5ECF6", "width": 0.5 }, "pattern": { "fillmode": "overlay", "size": 10, "solidity": 0.2 } }, "type": "bar" } ], "barpolar": [ { "marker": { "line": { "color": "#E5ECF6", "width": 0.5 }, "pattern": { "fillmode": "overlay", "size": 10, "solidity": 0.2 } }, "type": "barpolar" } ], "carpet": [ { "aaxis": { "endlinecolor": "#2a3f5f", "gridcolor": "white", "linecolor": "white", "minorgridcolor": "white", "startlinecolor": "#2a3f5f" }, "baxis": { "endlinecolor": "#2a3f5f", "gridcolor": "white", "linecolor": "white", "minorgridcolor": "white", "startlinecolor": "#2a3f5f" }, "type": "carpet" } ], "choropleth": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "type": "choropleth" } ], "contour": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0.0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1.0, "#f0f921" ] ], "type": "contour" } ], "contourcarpet": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "type": "contourcarpet" } ], "heatmap": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0.0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1.0, "#f0f921" ] ], "type": "heatmap" } ], "histogram": [ { "marker": { "pattern": { "fillmode": "overlay", "size": 10, "solidity": 0.2 } }, "type": "histogram" } ], "histogram2d": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0.0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1.0, "#f0f921" ] ], "type": "histogram2d" } ], "histogram2dcontour": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0.0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1.0, "#f0f921" ] ], "type": "histogram2dcontour" } ], "mesh3d": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "type": "mesh3d" } ], "parcoords": [ { "line": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "parcoords" } ], "pie": [ { "automargin": true, "type": "pie" } ], "scatter": [ { "fillpattern": { "fillmode": "overlay", "size": 10, "solidity": 0.2 }, "type": "scatter" } ], "scatter3d": [ { "line": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scatter3d" } ], "scattercarpet": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scattercarpet" } ], "scattergeo": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scattergeo" } ], "scattergl": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scattergl" } ], "scattermap": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scattermap" } ], "scattermapbox": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scattermapbox" } ], "scatterpolar": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scatterpolar" } ], "scatterpolargl": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scatterpolargl" } ], "scatterternary": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scatterternary" } ], "surface": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0.0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1.0, "#f0f921" ] ], "type": "surface" } ], "table": [ { "cells": { "fill": { "color": "#EBF0F8" }, "line": { "color": "white" } }, "header": { "fill": { "color": "#C8D4E3" }, "line": { "color": "white" } }, "type": "table" } ] }, "layout": { "annotationdefaults": { "arrowcolor": "#2a3f5f", "arrowhead": 0, "arrowwidth": 1 }, "autotypenumbers": "strict", "coloraxis": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "colorscale": { "diverging": [ [ 0, "#8e0152" ], [ 0.1, "#c51b7d" ], [ 0.2, "#de77ae" ], [ 0.3, "#f1b6da" ], [ 0.4, "#fde0ef" ], [ 0.5, "#f7f7f7" ], [ 0.6, "#e6f5d0" ], [ 0.7, "#b8e186" ], [ 0.8, "#7fbc41" ], [ 0.9, "#4d9221" ], [ 1, "#276419" ] ], "sequential": [ [ 0.0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1.0, "#f0f921" ] ], "sequentialminus": [ [ 0.0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1.0, "#f0f921" ] ] }, "colorway": [ "#636efa", "#EF553B", "#00cc96", "#ab63fa", "#FFA15A", "#19d3f3", "#FF6692", "#B6E880", "#FF97FF", "#FECB52" ], "font": { "color": "#2a3f5f" }, "geo": { "bgcolor": "white", "lakecolor": "white", "landcolor": "#E5ECF6", "showlakes": true, "showland": true, "subunitcolor": "white" }, "hoverlabel": { "align": "left" }, "hovermode": "closest", "mapbox": { "style": "light" }, "paper_bgcolor": "white", "plot_bgcolor": "#E5ECF6", "polar": { "angularaxis": { "gridcolor": "white", "linecolor": "white", "ticks": "" }, "bgcolor": "#E5ECF6", "radialaxis": { "gridcolor": "white", "linecolor": "white", "ticks": "" } }, "scene": { "xaxis": { "backgroundcolor": "#E5ECF6", "gridcolor": "white", "gridwidth": 2, "linecolor": "white", "showbackground": true, "ticks": "", "zerolinecolor": "white" }, "yaxis": { "backgroundcolor": "#E5ECF6", "gridcolor": "white", "gridwidth": 2, "linecolor": "white", "showbackground": true, "ticks": "", "zerolinecolor": "white" }, "zaxis": { "backgroundcolor": "#E5ECF6", "gridcolor": "white", "gridwidth": 2, "linecolor": "white", "showbackground": true, "ticks": "", "zerolinecolor": "white" } }, "shapedefaults": { "line": { "color": "#2a3f5f" } }, "ternary": { "aaxis": { "gridcolor": "white", "linecolor": "white", "ticks": "" }, "baxis": { "gridcolor": "white", "linecolor": "white", "ticks": "" }, "bgcolor": "#E5ECF6", "caxis": { "gridcolor": "white", "linecolor": "white", "ticks": "" } }, "title": { "x": 0.05 }, "xaxis": { "automargin": true, "gridcolor": "white", "linecolor": "white", "ticks": "", "title": { "standoff": 15 }, "zerolinecolor": "white", "zerolinewidth": 2 }, "yaxis": { "automargin": true, "gridcolor": "white", "linecolor": "white", "ticks": "", "title": { "standoff": 15 }, "zerolinecolor": "white", "zerolinewidth": 2 } } }, "title": { "font": { "color": "#0b0b0b", "size": 16 }, "text": "Migration programme cost vs doing nothing
Double-billing hits 2026-27 while existing term contracts run off · licences ramp-free for 6 months (order form), then bill at the run-rate · SOW-billed PS + managed services", "x": 0.02, "xanchor": "left" }, "xaxis": { "linecolor": "#c3c2b7", "showgrid": false, "tickfont": { "color": "#898781" }, "type": "category" }, "yaxis": { "gridcolor": "#e1e0d9", "tickfont": { "color": "#898781" }, "tickformat": "$~s", "zerolinecolor": "#c3c2b7", "zerolinewidth": 1.5 } } } }, "metadata": {}, "output_type": "display_data" } ], "source": [ "fig = go.Figure()\n", "for line in migration_costs.index:\n", " fig.add_trace(bar(X, [migration_costs.loc[line, y] for y in YEARS],\n", " line, COST_COLOR[line]))\n", "cum_mig = pd.Series([total_by_year[y] for y in YEARS]).cumsum()\n", "cum_nothing = pd.Series([donothing_by_year[y] for y in YEARS]).cumsum()\n", "fig.add_trace(cum_line(X, cum_mig, \"Cumulative — migration programme\"))\n", "fig.add_trace(cum_line(X, cum_nothing, \"Cumulative — do nothing\", color=CONTEXT, dash=\"dash\"))\n", "for i, y in enumerate(YEARS):\n", " fig.add_annotation(x=i, y=total_by_year[y], text=f\"{money(total_by_year[y])}\",\n", " showarrow=False, yshift=12, font=dict(size=12, color=INK))\n", "_delta = float(cum_mig.iloc[-1] - cum_nothing.iloc[-1])\n", "fig.add_annotation(x=len(YEARS) - 1, y=float(cum_mig.iloc[-1]),\n", " text=f\"3-yr {html_money(float(cum_mig.iloc[-1]))} — \"\n", " f\"{html_money(_delta)} above doing nothing\",\n", " showarrow=False, yshift=18, xshift=-70, font=dict(size=12, color=INK2))\n", "fig.update_layout(barmode=\"stack\")\n", "tei_layout(fig, \"Migration programme cost vs doing nothing\",\n", " \"Double-billing hits 2026-27 while existing term contracts run off · \"\n", " \"licences ramp-free for 6 months (order form), then bill at the run-rate · \"\n", " \"SOW-billed PS + managed services\", height=500)\n", "fig.show()" ] }, { "cell_type": "markdown", "id": "f24b1255", "metadata": {}, "source": [ "\n", "\n", "## 5 · Business case vs do nothing\n", "\n", "**Frame:** identical to the corrected notebook — baseline-relative, against *do nothing*\n", "(keep paying $7.3M/yr). Incremental cost = programme cost − baseline; net = scoped WFM\n", "benefits − incremental cost. The 2026–27 double-billing penalty and the 2028 cost-avoidance\n", "credit both fall out of this one frame.\n", "\n", "With the signed contracts in — **$3.2M/yr licences, 6-month ramp, SOW-billed PS, managed\n", "services** — the platform move alone still does not pay back inside the deck's own\n", "2026–2028 window (net ≈ −$1.5M). The **run-rate breakeven** KPI extrapolates past the\n", "window: steady state = (baseline − licence run-rate − managed services) + scoped WFM\n", "annual values, which carries the case to breakeven in early 2029. The deck frame (pitched\n", "$4.3M/yr, deck's year-1 PS lump, no managed services) is worse on both counts — the KPI\n", "table shows the columns side by side, so the walk from the Genesys report to the\n", "contracted reality stays explicit.\n", "\n", "*Not modelled: early-termination fees, and migration costs beyond the SOW.*\n" ] }, { "cell_type": "code", "execution_count": 11, "id": "d8293152", "metadata": { "execution": { "iopub.execute_input": "2026-07-08T15:15:49.063837Z", "iopub.status.busy": "2026-07-08T15:15:49.063671Z", "iopub.status.idle": "2026-07-08T15:15:49.069028Z", "shell.execute_reply": "2026-07-08T15:15:49.068435Z" } }, "outputs": [ { "data": { "application/mercury+json": { "model_id": "720f723fcedb42efa4441ea0f5e2aae2", "position": "sidebar", "widget": "SelectWidget" }, "application/vnd.jupyter.widget-view+json": { "model_id": "720f723fcedb42efa4441ea0f5e2aae2", "version_major": 2, "version_minor": 1 }, "text/plain": [ "" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "# ── NPV rate (Mercury sidebar — widgets only, no output) ────────────\n", "# NB: Mercury re-executes only cells BELOW a changed widget's cell,\n", "# so this cell constructs widgets ONLY — .value is read downstream.\n", "_npv_rate_w = mr.Select(label=\"NPV discount rate\", value=\"13.5% (deck)\",\n", " choices=[\"13.5% (deck)\", \"8.0% (CTM treasury)\"])" ] }, { "cell_type": "code", "execution_count": 12, "id": "c89973af", "metadata": { "execution": { "iopub.execute_input": "2026-07-08T15:15:49.070865Z", "iopub.status.busy": "2026-07-08T15:15:49.070716Z", "iopub.status.idle": "2026-07-08T15:15:49.076308Z", "shell.execute_reply": "2026-07-08T15:15:49.075521Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "net by year: {2026: '-$2.9M', 2027: '-$4.5M', 2028: '$5.9M'} → 3-yr -$1.5M\n", "steady-state run-rate saving $6.6M/yr ($4.1M licences − $411K managed services + $2.9M WFM annuals) → breakeven 39 months (~Mar 2029, extrapolated)\n" ] } ], "source": [ "# Baseline-relative frame (tokencalc.appendix4.case_flows / case_kpis):\n", "# baseline = do nothing = keep paying $7.3M/yr.\n", "DISCOUNT_RATE = (TCO_VERBATIM[\"npv_discount_rate\"]\n", " if _npv_rate_w.value.startswith(\"13.5\") else 0.08)\n", "inc, net_by = case_flows(total_by_year, ben_by_year)\n", "kpi = case_kpis(inc, net_by, DISCOUNT_RATE)\n", "\n", "# Steady-state run-rate once contracts end and the ramp is over\n", "# (tokencalc.migration_wfm) — fills the end-2028 deficit.\n", "RUNRATE_SAVING = runrate_saving_annual(LICENCE_ANNUAL, WFM_REGIONS,\n", " managed_annual=MANAGED_SERVICES_ANNUAL)\n", "BREAKEVEN = runrate_breakeven_label(net_by, RUNRATE_SAVING)\n", "\n", "\n", "def fmt_roi(k):\n", " return f\"{k['roi']:.0%}\" if k[\"roi\"] is not None else \"n/a — net cost saving\"\n", "\n", "\n", "backstage(f\"net by year: { {y: money(v) for y, v in net_by.items()} } \"\n", " f\"→ 3-yr {money(kpi['net_3yr'])}\")\n", "backstage(f\"steady-state run-rate saving {money(RUNRATE_SAVING)}/yr \"\n", " f\"({money(BASELINE_ANNUAL - LICENCE_ANNUAL)} licences − \"\n", " f\"{money(MANAGED_SERVICES_ANNUAL)} managed services + \"\n", " f\"{money(wfm_annual_runrate(WFM_REGIONS))} WFM annuals) → breakeven {BREAKEVEN}\")" ] }, { "cell_type": "code", "execution_count": 13, "id": "9d135a19", "metadata": { "execution": { "iopub.execute_input": "2026-07-08T15:15:49.077976Z", "iopub.status.busy": "2026-07-08T15:15:49.077813Z", "iopub.status.idle": "2026-07-08T15:15:49.102760Z", "shell.execute_reply": "2026-07-08T15:15:49.102141Z" } }, "outputs": [ { "data": { "application/vnd.plotly.v1+json": { "config": { "plotlyServerURL": "https://plot.ly" }, "data": [ { "hovertemplate": "%{fullData.name}: %{y:$,.0f}", "marker": { "color": "#1baf7a", "line": { "color": "#fcfcfb", "width": 2 } }, "name": "WFM benefits (verbatim, scoped)", "type": "bar", "x": [ "2026", "2027", "2028" ], "y": [ 0.0, 107692.30769230769, 2206307.692307692 ] }, { "hovertemplate": "%{fullData.name}: %{y:$,.0f}", "marker": { "color": "#e34948", "line": { "color": "#fcfcfb", "width": 2 } }, "name": "Incremental cost vs $7.3M/yr baseline", "type": "bar", "x": [ "2026", "2027", "2028" ], "y": [ -2882453.84, -4623642.640000001, 3689081.6 ] }, { "hovertemplate": "%{fullData.name}: %{y:$,.0f}", "line": { "color": "#52514e", "width": 2 }, "marker": { "line": { "color": "#fcfcfb", "width": 2 }, "size": 8 }, "mode": "lines+markers", "name": "Cumulative net", "type": "scatter", "x": [ "2026", "2027", "2028" ], "y": { "bdata": "uB6F68r9RcHYFgcLATlcwRiuR+Em7zbB", "dtype": "f8" } } ], "layout": { "annotations": [ { "font": { "color": "#0b0b0b", "size": 12 }, "showarrow": false, "text": "-$2.9M", "x": 0, "y": -2882453.84, "yshift": -14 }, { "font": { "color": "#0b0b0b", "size": 12 }, "showarrow": false, "text": "-$7.4M", "x": 1, "y": -7398404.1723076925, "yshift": -14 }, { "font": { "color": "#0b0b0b", "size": 12 }, "showarrow": false, "text": "-$1.5M", "x": 2, "y": -1503014.8800000008, "yshift": -14 }, { "align": "left", "bgcolor": "#fcfcfb", "bordercolor": "#e1e0d9", "borderwidth": 1, "font": { "color": "#52514e", "size": 12 }, "showarrow": false, "text": "3-yr net -$1.5M · ROI -39%
NPV@13.5% -$2.0M · payback beyond 2028
run-rate $6.6M/yr post-window → breakeven 39 months (~Mar 2029, extrapolated)", "x": 0.01, "xref": "paper", "y": 0.98, "yref": "paper" } ], "bargap": 0.45, "barmode": "relative", "font": { "color": "#52514e", "family": "system-ui, -apple-system, \"Segoe UI\", sans-serif", "size": 12 }, "height": 500, "hovermode": "x unified", "legend": { "font": { "color": "#52514e", "size": 11 }, "orientation": "h", "x": 0, "y": -0.1, "yanchor": "top" }, "margin": { "b": 80, "l": 70, "r": 30, "t": 70 }, "paper_bgcolor": "#fcfcfb", "plot_bgcolor": "#fcfcfb", "template": { "data": { "bar": [ { "error_x": { "color": "#2a3f5f" }, "error_y": { "color": "#2a3f5f" }, "marker": { "line": { "color": "#E5ECF6", "width": 0.5 }, "pattern": { "fillmode": "overlay", "size": 10, "solidity": 0.2 } }, "type": "bar" } ], "barpolar": [ { "marker": { "line": { "color": "#E5ECF6", "width": 0.5 }, "pattern": { "fillmode": "overlay", "size": 10, "solidity": 0.2 } }, "type": "barpolar" } ], "carpet": [ { "aaxis": { "endlinecolor": "#2a3f5f", "gridcolor": "white", "linecolor": "white", "minorgridcolor": "white", "startlinecolor": "#2a3f5f" }, "baxis": { "endlinecolor": "#2a3f5f", "gridcolor": "white", "linecolor": "white", "minorgridcolor": "white", "startlinecolor": "#2a3f5f" }, "type": "carpet" } ], "choropleth": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "type": "choropleth" } ], "contour": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0.0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1.0, "#f0f921" ] ], "type": "contour" } ], "contourcarpet": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "type": "contourcarpet" } ], "heatmap": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0.0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1.0, "#f0f921" ] ], "type": "heatmap" } ], "histogram": [ { "marker": { "pattern": { "fillmode": "overlay", "size": 10, "solidity": 0.2 } }, "type": "histogram" } ], "histogram2d": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0.0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1.0, "#f0f921" ] ], "type": "histogram2d" } ], "histogram2dcontour": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0.0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1.0, "#f0f921" ] ], "type": "histogram2dcontour" } ], "mesh3d": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "type": "mesh3d" } ], "parcoords": [ { "line": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "parcoords" } ], "pie": [ { "automargin": true, "type": "pie" } ], "scatter": [ { "fillpattern": { "fillmode": "overlay", "size": 10, "solidity": 0.2 }, "type": "scatter" } ], "scatter3d": [ { "line": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scatter3d" } ], "scattercarpet": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scattercarpet" } ], "scattergeo": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scattergeo" } ], "scattergl": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scattergl" } ], "scattermap": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scattermap" } ], "scattermapbox": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scattermapbox" } ], "scatterpolar": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scatterpolar" } ], "scatterpolargl": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scatterpolargl" } ], "scatterternary": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scatterternary" } ], "surface": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0.0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1.0, "#f0f921" ] ], "type": "surface" } ], "table": [ { "cells": { "fill": { "color": "#EBF0F8" }, "line": { "color": "white" } }, "header": { "fill": { "color": "#C8D4E3" }, "line": { "color": "white" } }, "type": "table" } ] }, "layout": { "annotationdefaults": { "arrowcolor": "#2a3f5f", "arrowhead": 0, "arrowwidth": 1 }, "autotypenumbers": "strict", "coloraxis": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "colorscale": { "diverging": [ [ 0, "#8e0152" ], [ 0.1, "#c51b7d" ], [ 0.2, "#de77ae" ], [ 0.3, "#f1b6da" ], [ 0.4, "#fde0ef" ], [ 0.5, "#f7f7f7" ], [ 0.6, "#e6f5d0" ], [ 0.7, "#b8e186" ], [ 0.8, "#7fbc41" ], [ 0.9, "#4d9221" ], [ 1, "#276419" ] ], "sequential": [ [ 0.0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1.0, "#f0f921" ] ], "sequentialminus": [ [ 0.0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1.0, "#f0f921" ] ] }, "colorway": [ "#636efa", "#EF553B", "#00cc96", "#ab63fa", "#FFA15A", "#19d3f3", "#FF6692", "#B6E880", "#FF97FF", "#FECB52" ], "font": { "color": "#2a3f5f" }, "geo": { "bgcolor": "white", "lakecolor": "white", "landcolor": "#E5ECF6", "showlakes": true, "showland": true, "subunitcolor": "white" }, "hoverlabel": { "align": "left" }, "hovermode": "closest", "mapbox": { "style": "light" }, "paper_bgcolor": "white", "plot_bgcolor": "#E5ECF6", "polar": { "angularaxis": { "gridcolor": "white", "linecolor": "white", "ticks": "" }, "bgcolor": "#E5ECF6", "radialaxis": { "gridcolor": "white", "linecolor": "white", "ticks": "" } }, "scene": { "xaxis": { "backgroundcolor": "#E5ECF6", "gridcolor": "white", "gridwidth": 2, "linecolor": "white", "showbackground": true, "ticks": "", "zerolinecolor": "white" }, "yaxis": { "backgroundcolor": "#E5ECF6", "gridcolor": "white", "gridwidth": 2, "linecolor": "white", "showbackground": true, "ticks": "", "zerolinecolor": "white" }, "zaxis": { "backgroundcolor": "#E5ECF6", "gridcolor": "white", "gridwidth": 2, "linecolor": "white", "showbackground": true, "ticks": "", "zerolinecolor": "white" } }, "shapedefaults": { "line": { "color": "#2a3f5f" } }, "ternary": { "aaxis": { "gridcolor": "white", "linecolor": "white", "ticks": "" }, "baxis": { "gridcolor": "white", "linecolor": "white", "ticks": "" }, "bgcolor": "#E5ECF6", "caxis": { "gridcolor": "white", "linecolor": "white", "ticks": "" } }, "title": { "x": 0.05 }, "xaxis": { "automargin": true, "gridcolor": "white", "linecolor": "white", "ticks": "", "title": { "standoff": 15 }, "zerolinecolor": "white", "zerolinewidth": 2 }, "yaxis": { "automargin": true, "gridcolor": "white", "linecolor": "white", "ticks": "", "title": { "standoff": 15 }, "zerolinecolor": "white", "zerolinewidth": 2 } } }, "title": { "font": { "color": "#0b0b0b", "size": 16 }, "text": "Migration + WFM — benefits vs incremental cost
Baseline = keep paying $7.3M/yr · the platform swap alone does not pay back inside the deck's own 3-year window", "x": 0.02, "xanchor": "left" }, "xaxis": { "linecolor": "#c3c2b7", "showgrid": false, "tickfont": { "color": "#898781" }, "type": "category" }, "yaxis": { "gridcolor": "#e1e0d9", "tickfont": { "color": "#898781" }, "tickformat": "$~s", "zerolinecolor": "#c3c2b7", "zerolinewidth": 1.5 } } } }, "metadata": {}, "output_type": "display_data" } ], "source": [ "fig = go.Figure()\n", "fig.add_trace(bar(X, [ben_by_year[y] for y in YEARS],\n", " \"WFM benefits (verbatim, scoped)\", \"#1baf7a\"))\n", "fig.add_trace(bar(X, [-inc[y] for y in YEARS],\n", " \"Incremental cost vs $7.3M/yr baseline\", \"#e34948\"))\n", "cum_net = pd.Series([net_by[y] for y in YEARS]).cumsum()\n", "fig.add_trace(cum_line(X, cum_net, \"Cumulative net\"))\n", "for i, c in enumerate(cum_net):\n", " fig.add_annotation(x=i, y=float(c), text=f\"{money(float(c))}\", showarrow=False,\n", " yshift=14 if c >= 0 else -14, font=dict(size=12, color=INK))\n", "fig.update_layout(barmode=\"relative\")\n", "fig.add_annotation(\n", " xref=\"paper\", yref=\"paper\", x=0.01, y=0.98, align=\"left\", showarrow=False,\n", " font=dict(size=12, color=INK2), bgcolor=SURFACE, bordercolor=GRID, borderwidth=1,\n", " text=(f\"3-yr net {html_money(kpi['net_3yr'])} · \"\n", " f\"ROI {fmt_roi(kpi)}
\"\n", " f\"NPV@{DISCOUNT_RATE:.1%} {html_money(kpi['npv'])} · \"\n", " f\"payback {kpi['payback']}
\"\n", " f\"run-rate {html_money(RUNRATE_SAVING)}/yr post-window → \"\n", " f\"breakeven {BREAKEVEN}\"))\n", "tei_layout(fig, \"Migration + WFM — benefits vs incremental cost\",\n", " \"Baseline = keep paying $7.3M/yr · the platform swap alone does not pay \"\n", " \"back inside the deck's own 3-year window\", height=500)\n", "fig.show()" ] }, { "cell_type": "code", "execution_count": 14, "id": "7ca576f1", "metadata": { "execution": { "iopub.execute_input": "2026-07-08T15:15:49.105099Z", "iopub.status.busy": "2026-07-08T15:15:49.104937Z", "iopub.status.idle": "2026-07-08T15:15:49.115327Z", "shell.execute_reply": "2026-07-08T15:15:49.114734Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "The signed contracts move the 3-yr case by $2.2M vs the deck frame — the licence correction helps; SOW managed services cost against it. For the with-AI case in the same frame, open the Corrected Business Case notebook.\n" ] }, { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
Deck frame ($4.3M/yr, deck PS)Contracted ($3.2M/yr + SOW)
3-yr WFM benefits (verbatim, scoped)$2.3M$2.3M
3-yr incremental cost$6.0M$3.8M
3-yr net-$3.7M-$1.5M
ROI (net ÷ incremental cost)-62%-39%
NPV @ 13.5% (deck rate)-$3.8M-$2.0M
NPV @ 8.0% (CTM treasury)-$3.8M-$1.9M
Payback (2026–28 window)beyond 2028beyond 2028
Steady-state run-rate saving$5.9M/yr$6.6M/yr
Breakeven44 months (~Aug 2029, extrapolated)39 months (~Mar 2029, extrapolated)
\n", "
" ], "text/plain": [ " Deck frame ($4.3M/yr, deck PS) \\\n", "3-yr WFM benefits (verbatim, scoped) $2.3M \n", "3-yr incremental cost $6.0M \n", "3-yr net -$3.7M \n", "ROI (net ÷ incremental cost) -62% \n", "NPV @ 13.5% (deck rate) -$3.8M \n", "NPV @ 8.0% (CTM treasury) -$3.8M \n", "Payback (2026–28 window) beyond 2028 \n", "Steady-state run-rate saving $5.9M/yr \n", "Breakeven 44 months (~Aug 2029, extrapolated) \n", "\n", " Contracted ($3.2M/yr + SOW) \n", "3-yr WFM benefits (verbatim, scoped) $2.3M \n", "3-yr incremental cost $3.8M \n", "3-yr net -$1.5M \n", "ROI (net ÷ incremental cost) -39% \n", "NPV @ 13.5% (deck rate) -$2.0M \n", "NPV @ 8.0% (CTM treasury) -$1.9M \n", "Payback (2026–28 window) beyond 2028 \n", "Steady-state run-rate saving $6.6M/yr \n", "Breakeven 39 months (~Mar 2029, extrapolated) " ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "def kpi_display(k, net, runrate, breakeven):\n", " return {\n", " \"3-yr WFM benefits (verbatim, scoped)\": money(k[\"benefits_3yr\"]),\n", " \"3-yr incremental cost\": money(k[\"incremental_cost_3yr\"]),\n", " \"3-yr net\": money(k[\"net_3yr\"]),\n", " \"ROI (net ÷ incremental cost)\": fmt_roi(k),\n", " f\"NPV @ {k['discount_rate']:.1%} (deck rate)\": money(k[\"npv\"]),\n", " \"NPV @ 8.0% (CTM treasury)\": money(npv([net[y] for y in YEARS], 0.08)),\n", " \"Payback (2026–28 window)\": k[\"payback\"],\n", " \"Steady-state run-rate saving\": f\"{money(runrate)}/yr\",\n", " \"Breakeven\": breakeven,\n", " }\n", "\n", "\n", "# Deck-anchored comparison: the deck's own frame — pitched licence rate,\n", "# verbatim year-1 PS lump, no managed services (the SOW lines are contracted\n", "# facts the deck never carried).\n", "_lic_deck = licence_costs_by_year(RAMP_MONTHS, TCO_VERBATIM[\"ccaas_annual\"])\n", "_ps_deck = ps_costs_by_year()\n", "total_deck = {y: current_by_year[y] + _lic_deck[y] + _ps_deck[y] for y in YEARS}\n", "inc_deck, net_deck = case_flows(total_deck, ben_by_year)\n", "kpi_deck = case_kpis(inc_deck, net_deck, DISCOUNT_RATE)\n", "RUNRATE_DECK = runrate_saving_annual(TCO_VERBATIM[\"ccaas_annual\"], WFM_REGIONS)\n", "BREAKEVEN_DECK = runrate_breakeven_label(net_deck, RUNRATE_DECK)\n", "\n", "kpis_fmt = pd.DataFrame({\n", " f\"Deck frame ({money(TCO_VERBATIM['ccaas_annual'])}/yr, deck PS)\":\n", " kpi_display(kpi_deck, net_deck, RUNRATE_DECK, BREAKEVEN_DECK),\n", " f\"Contracted ({money(LICENCE_ANNUAL)}/yr + SOW)\":\n", " kpi_display(kpi, net_by, RUNRATE_SAVING, BREAKEVEN),\n", "})\n", "print(f\"The signed contracts move the 3-yr case by \"\n", " f\"{money(kpi['net_3yr'] - kpi_deck['net_3yr'])} vs the deck frame — the licence \"\n", " f\"correction helps; SOW managed services cost against it. \"\n", " f\"For the with-AI case in the same frame, open the Corrected Business Case notebook.\")\n", "display(kpis_fmt)" ] }, { "cell_type": "markdown", "id": "a7329ce6", "metadata": {}, "source": [ "\n", "\n", "## 6 · Sensitivity — ramp × licence run-rate\n", "\n", "The two commercial levers Genesys controls directly, swept against the 3-yr net:\n", "the **ramp programme length** (licence-free months) and the **licence run-rate**\n", "(contracted $3.2M/yr vs the deck's pitched $4.3M/yr; 🟡 WEM-SKU inclusion either way).\n", "Contract **termination dates**\n", "are the other big lever — edit the per-region dates in section 1 and the whole\n", "notebook recomputes." ] }, { "cell_type": "code", "execution_count": 15, "id": "4ab17e24", "metadata": { "execution": { "iopub.execute_input": "2026-07-08T15:15:49.118883Z", "iopub.status.busy": "2026-07-08T15:15:49.118721Z", "iopub.status.idle": "2026-07-08T15:15:49.156840Z", "shell.execute_reply": "2026-07-08T15:15:49.156126Z" } }, "outputs": [ { "data": { "application/vnd.plotly.v1+json": { "config": { "plotlyServerURL": "https://plot.ly" }, "data": [ { "colorscale": [ [ 0.0, "#e34948" ], [ 0.5, "#f0efe9" ], [ 1.0, "#2a78d6" ] ], "hovertemplate": "%{y} × %{x}: %{z:$,.0f}", "showscale": false, "type": "heatmap", "x": [ "$3.2M/yr (contracted)", "$3.8M/yr", "$4.3M/yr (deck)" ], "xgap": 2, "y": [ "0-mo ramp", "6-mo ramp", "12-mo ramp", "18-mo ramp", "24-mo ramp" ], "ygap": 2, "z": [ [ -3103014.880000001, -4903014.880000001, -6403014.880000001 ], [ -1503014.8800000008, -3003014.880000001, -4253014.880000001 ], [ 96985.11999999918, -1103014.8800000008, -2103014.880000001 ], [ 1696985.1199999987, 796985.1199999987, 46985.119999998715 ], [ 3296985.119999999, 2696985.119999999, 2196985.119999999 ] ], "zmid": 0 } ], "layout": { "annotations": [ { "font": { "color": "#0b0b0b", "size": 12 }, "showarrow": false, "text": "-$3.1M", "x": 0, "y": 0 }, { "font": { "color": "#ffffff", "size": 12 }, "showarrow": false, "text": "-$4.9M", "x": 1, "y": 0 }, { "font": { "color": "#ffffff", "size": 12 }, "showarrow": false, "text": "-$6.4M", "x": 2, "y": 0 }, { "font": { "color": "#0b0b0b", "size": 12 }, "showarrow": false, "text": "-$1.5M", "x": 0, "y": 1 }, { "font": { "color": "#0b0b0b", "size": 12 }, "showarrow": false, "text": "-$3.0M", "x": 1, "y": 1 }, { "font": { "color": "#ffffff", "size": 12 }, "showarrow": false, "text": "-$4.3M", "x": 2, "y": 1 }, { "font": { "color": "#0b0b0b", "size": 12 }, "showarrow": false, "text": "$97K", "x": 0, "y": 2 }, { "font": { "color": "#0b0b0b", "size": 12 }, "showarrow": false, "text": "-$1.1M", "x": 1, "y": 2 }, { "font": { "color": "#0b0b0b", "size": 12 }, "showarrow": false, "text": "-$2.1M", "x": 2, "y": 2 }, { "font": { "color": "#0b0b0b", "size": 12 }, "showarrow": false, "text": "$1.7M", "x": 0, "y": 3 }, { "font": { "color": "#0b0b0b", "size": 12 }, "showarrow": false, "text": "$797K", "x": 1, "y": 3 }, { "font": { "color": "#0b0b0b", "size": 12 }, "showarrow": false, "text": "$47K", "x": 2, "y": 3 }, { "font": { "color": "#0b0b0b", "size": 12 }, "showarrow": false, "text": "$3.3M", "x": 0, "y": 4 }, { "font": { "color": "#0b0b0b", "size": 12 }, "showarrow": false, "text": "$2.7M", "x": 1, "y": 4 }, { "font": { "color": "#0b0b0b", "size": 12 }, "showarrow": false, "text": "$2.2M", "x": 2, "y": 4 } ], "bargap": 0.45, "font": { "color": "#52514e", "family": "system-ui, -apple-system, \"Segoe UI\", sans-serif", "size": 12 }, "height": 400, "hovermode": "closest", "legend": { "font": { "color": "#52514e", "size": 11 }, "orientation": "h", "x": 0, "y": -0.1, "yanchor": "top" }, "margin": { "b": 80, "l": 70, "r": 30, "t": 70 }, "paper_bgcolor": "#fcfcfb", "plot_bgcolor": "#fcfcfb", "template": { "data": { "bar": [ { "error_x": { "color": "#2a3f5f" }, "error_y": { "color": "#2a3f5f" }, "marker": { "line": { "color": "#E5ECF6", "width": 0.5 }, "pattern": { "fillmode": "overlay", "size": 10, "solidity": 0.2 } }, "type": "bar" } ], "barpolar": [ { "marker": { "line": { "color": "#E5ECF6", "width": 0.5 }, "pattern": { "fillmode": "overlay", "size": 10, "solidity": 0.2 } }, "type": "barpolar" } ], "carpet": [ { "aaxis": { "endlinecolor": "#2a3f5f", "gridcolor": "white", "linecolor": "white", "minorgridcolor": "white", "startlinecolor": "#2a3f5f" }, "baxis": { "endlinecolor": "#2a3f5f", "gridcolor": "white", "linecolor": "white", "minorgridcolor": "white", "startlinecolor": "#2a3f5f" }, "type": "carpet" } ], "choropleth": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "type": "choropleth" } ], "contour": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0.0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1.0, "#f0f921" ] ], "type": "contour" } ], "contourcarpet": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "type": "contourcarpet" } ], "heatmap": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0.0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1.0, "#f0f921" ] ], "type": "heatmap" } ], "histogram": [ { "marker": { "pattern": { "fillmode": "overlay", "size": 10, "solidity": 0.2 } }, "type": "histogram" } ], "histogram2d": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0.0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1.0, "#f0f921" ] ], "type": "histogram2d" } ], "histogram2dcontour": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0.0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1.0, "#f0f921" ] ], "type": "histogram2dcontour" } ], "mesh3d": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "type": "mesh3d" } ], "parcoords": [ { "line": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "parcoords" } ], "pie": [ { "automargin": true, "type": "pie" } ], "scatter": [ { "fillpattern": { "fillmode": "overlay", "size": 10, "solidity": 0.2 }, "type": "scatter" } ], "scatter3d": [ { "line": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scatter3d" } ], "scattercarpet": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scattercarpet" } ], "scattergeo": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scattergeo" } ], "scattergl": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scattergl" } ], "scattermap": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scattermap" } ], "scattermapbox": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scattermapbox" } ], "scatterpolar": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scatterpolar" } ], "scatterpolargl": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scatterpolargl" } ], "scatterternary": [ { "marker": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "type": "scatterternary" } ], "surface": [ { "colorbar": { "outlinewidth": 0, "ticks": "" }, "colorscale": [ [ 0.0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1.0, "#f0f921" ] ], "type": "surface" } ], "table": [ { "cells": { "fill": { "color": "#EBF0F8" }, "line": { "color": "white" } }, "header": { "fill": { "color": "#C8D4E3" }, "line": { "color": "white" } }, "type": "table" } ] }, "layout": { "annotationdefaults": { "arrowcolor": "#2a3f5f", "arrowhead": 0, "arrowwidth": 1 }, "autotypenumbers": "strict", "coloraxis": { "colorbar": { "outlinewidth": 0, "ticks": "" } }, "colorscale": { "diverging": [ [ 0, "#8e0152" ], [ 0.1, "#c51b7d" ], [ 0.2, "#de77ae" ], [ 0.3, "#f1b6da" ], [ 0.4, "#fde0ef" ], [ 0.5, "#f7f7f7" ], [ 0.6, "#e6f5d0" ], [ 0.7, "#b8e186" ], [ 0.8, "#7fbc41" ], [ 0.9, "#4d9221" ], [ 1, "#276419" ] ], "sequential": [ [ 0.0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1.0, "#f0f921" ] ], "sequentialminus": [ [ 0.0, "#0d0887" ], [ 0.1111111111111111, "#46039f" ], [ 0.2222222222222222, "#7201a8" ], [ 0.3333333333333333, "#9c179e" ], [ 0.4444444444444444, "#bd3786" ], [ 0.5555555555555556, "#d8576b" ], [ 0.6666666666666666, "#ed7953" ], [ 0.7777777777777778, "#fb9f3a" ], [ 0.8888888888888888, "#fdca26" ], [ 1.0, "#f0f921" ] ] }, "colorway": [ "#636efa", "#EF553B", "#00cc96", "#ab63fa", "#FFA15A", "#19d3f3", "#FF6692", "#B6E880", "#FF97FF", "#FECB52" ], "font": { "color": "#2a3f5f" }, "geo": { "bgcolor": "white", "lakecolor": "white", "landcolor": "#E5ECF6", "showlakes": true, "showland": true, "subunitcolor": "white" }, "hoverlabel": { "align": "left" }, "hovermode": "closest", "mapbox": { "style": "light" }, "paper_bgcolor": "white", "plot_bgcolor": "#E5ECF6", "polar": { "angularaxis": { "gridcolor": "white", "linecolor": "white", "ticks": "" }, "bgcolor": "#E5ECF6", "radialaxis": { "gridcolor": "white", "linecolor": "white", "ticks": "" } }, "scene": { "xaxis": { "backgroundcolor": "#E5ECF6", "gridcolor": "white", "gridwidth": 2, "linecolor": "white", "showbackground": true, "ticks": "", "zerolinecolor": "white" }, "yaxis": { "backgroundcolor": "#E5ECF6", "gridcolor": "white", "gridwidth": 2, "linecolor": "white", "showbackground": true, "ticks": "", "zerolinecolor": "white" }, "zaxis": { "backgroundcolor": "#E5ECF6", "gridcolor": "white", "gridwidth": 2, "linecolor": "white", "showbackground": true, "ticks": "", "zerolinecolor": "white" } }, "shapedefaults": { "line": { "color": "#2a3f5f" } }, "ternary": { "aaxis": { "gridcolor": "white", "linecolor": "white", "ticks": "" }, "baxis": { "gridcolor": "white", "linecolor": "white", "ticks": "" }, "bgcolor": "#E5ECF6", "caxis": { "gridcolor": "white", "linecolor": "white", "ticks": "" } }, "title": { "x": 0.05 }, "xaxis": { "automargin": true, "gridcolor": "white", "linecolor": "white", "ticks": "", "title": { "standoff": 15 }, "zerolinecolor": "white", "zerolinewidth": 2 }, "yaxis": { "automargin": true, "gridcolor": "white", "linecolor": "white", "ticks": "", "title": { "standoff": 15 }, "zerolinecolor": "white", "zerolinewidth": 2 } } }, "title": { "font": { "color": "#0b0b0b", "size": 16 }, "text": "3-yr net across ramp length × licence run-rate
Diverging scale centered at $0 · SOW PS + managed services included · WFM scope and contract dates held at current inputs", "x": 0.02, "xanchor": "left" }, "xaxis": { "linecolor": "#c3c2b7", "showgrid": false, "tickfont": { "color": "#898781" }, "type": "category" }, "yaxis": { "gridcolor": "#e1e0d9", "showgrid": false, "tickfont": { "color": "#898781" }, "zerolinecolor": "#c3c2b7", "zerolinewidth": 1.5 } } } }, "metadata": {}, "output_type": "display_data" }, { "name": "stdout", "output_type": "stream", "text": [ "Contracted corner (6-mo ramp × $3.2M/yr): -$1.5M · deck-rate corner (6-mo × $4.3M/yr): -$4.3M — the licence correction recovers $2.8M but the 3-yr window stays negative; breakeven rides the post-2028 run-rate.\n" ] } ], "source": [ "# ── 3-yr net across ramp months × licence run-rate ───────────────────\n", "RAMP_GRID = [0, 6, 12, 18, 24]\n", "LIC_GRID = [3_200_000, 3_800_000, 4_300_000]\n", "_LIC_TAG = {3_200_000: \" (contracted)\", 4_300_000: \" (deck)\"}\n", "\n", "\n", "def net_3yr(ramp_months, licence_annual):\n", " lby = licence_costs_by_year(ramp_months, licence_annual)\n", " t = {y: current_by_year[y] + lby[y] + ps_by_year[y] + managed_by_year[y]\n", " for y in YEARS}\n", " _, n = case_flows(t, ben_by_year)\n", " return sum(n.values())\n", "\n", "\n", "z = [[net_3yr(rm, lc) for lc in LIC_GRID] for rm in RAMP_GRID]\n", "_zabs = max(abs(v) for row in z for v in row)\n", "fig = go.Figure(go.Heatmap(\n", " z=z, x=[f\"{money(lc)}/yr{_LIC_TAG.get(lc, '')}\" for lc in LIC_GRID],\n", " y=[f\"{rm}-mo ramp\" for rm in RAMP_GRID],\n", " zmid=0, colorscale=DIVERGING, showscale=False, xgap=2, ygap=2,\n", " hovertemplate=\"%{y} × %{x}: %{z:$,.0f}\"))\n", "for i, rm in enumerate(RAMP_GRID):\n", " for j, lc in enumerate(LIC_GRID):\n", " v = z[i][j]\n", " fig.add_annotation(x=j, y=i, text=f\"{money(v)}\", showarrow=False,\n", " font=dict(size=12,\n", " color=\"#ffffff\" if abs(v) > 0.65 * _zabs else INK))\n", "tei_layout(fig, \"3-yr net across ramp length × licence run-rate\",\n", " \"Diverging scale centered at $0 · SOW PS + managed services included · \"\n", " \"WFM scope and contract dates held at current inputs\", height=400)\n", "fig.update_layout(xaxis=dict(showgrid=False), yaxis=dict(showgrid=False, tickformat=None),\n", " hovermode=\"closest\")\n", "fig.show()\n", "print(f\"Contracted corner ({RAMP_MONTHS}-mo ramp × {money(LICENCE_ANNUAL)}/yr): \"\n", " f\"{money(net_3yr(RAMP_MONTHS, LICENCE_ANNUAL))} · deck-rate corner \"\n", " f\"({RAMP_MONTHS}-mo × {money(TCO_VERBATIM['ccaas_annual'])}/yr): \"\n", " f\"{money(net_3yr(RAMP_MONTHS, TCO_VERBATIM['ccaas_annual']))} — the licence \"\n", " f\"correction recovers \"\n", " f\"{money(net_3yr(RAMP_MONTHS, LICENCE_ANNUAL) - net_3yr(RAMP_MONTHS, TCO_VERBATIM['ccaas_annual']))} \"\n", " f\"but the 3-yr window stays negative; breakeven rides the post-2028 run-rate.\")" ] }, { "cell_type": "markdown", "id": "13d4932f", "metadata": {}, "source": [ "\n", "\n", "## 7 · Verification & assertions\n", "\n", "The next cell re-derives key numbers independently and **raises on any failure**, so\n", "`jupyter nbconvert --execute` acts as a regression gate for this notebook. Engine pins\n", "use explicit default arguments so the gate tests `tokencalc`, not the current widget\n", "state; live-state checks are guarded so the cell stays valid after you edit the inputs." ] }, { "cell_type": "code", "execution_count": 16, "id": "939417e4", "metadata": { "execution": { "iopub.execute_input": "2026-07-08T15:15:49.160025Z", "iopub.status.busy": "2026-07-08T15:15:49.159836Z", "iopub.status.idle": "2026-07-08T15:15:49.177363Z", "shell.execute_reply": "2026-07-08T15:15:49.176452Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "All assertions passed.\n", " WFM benefits 3-yr $2.3M · programme cost 3-yr $25.7M · net 3-yr -$1.5M · breakeven 39 months (~Mar 2029, extrapolated)\n" ] } ], "source": [ "def _approx(got, want, tol=0.5):\n", " assert abs(got - want) <= tol, f\"got {got:,.2f}, want {want:,.2f}\"\n", "\n", "\n", "# Section 1 — contract mechanics (engine pins, explicit defaults)\n", "_seed_check = current_state_inputs(sites)\n", "_approx(_seed_check[\"annual_cost\"].sum(), 7_300_000)\n", "assert DEFAULT_RAMP_MONTHS == 6 # 🟢 order form\n", "_l_deck = licence_costs_by_year() # deck rate at the real ramp\n", "_approx(_l_deck[2026], 2_150_000)\n", "_approx(_l_deck[2027], 4_300_000)\n", "_approx(licence_costs_by_year(12)[2026], 0) # mechanics: 12-mo ramp zeroes 2026\n", "_contracts_at_default = (\n", " (current_state[\"annual_cost\"] - _seed_check[\"annual_cost\"]).abs().max() < 1\n", " and all(t == dt.date(2027, 12, 31) for t in current_state[\"contract_termination\"])\n", ")\n", "if _contracts_at_default:\n", " # widget seeds are whole dollars → per-region rounding of ≤ $0.50\n", " _approx(current_by_year[2026], 7_300_000, tol=len(REGIONS))\n", " _approx(current_by_year[2028], 0)\n", "\n", "# NTT SOW — billing milestones + managed services (🟢 contractual)\n", "_approx(PS_CONTRACTED_TOTAL, 2_025_446.48, tol=0.01)\n", "_ps_c = ps_costs_by_year(contracted=True)\n", "_approx(_ps_c[2026], 607_633.94 + 405_089.30 + 167_000, tol=0.01) # 30% + 20% + training\n", "_approx(_ps_c[2027], 607_633.94 + 405_089.30, tol=0.01) # 30% + 20%\n", "_ms_c = managed_services_by_year()\n", "_approx(_ms_c[2026], MANAGED_SERVICES_ANNUAL * 3 / 12, tol=0.01) # Oct–Dec after go-live\n", "_approx(_ms_c[2027], MANAGED_SERVICES_ANNUAL, tol=0.01)\n", "\n", "# Sections 2-3 — WFM scope & phasing at engine defaults\n", "_, _, _rollout_d = build_rollouts(sites)\n", "_wfm_d = wfm_benefits_by_year(_rollout_d) # NA + ANZ + ASIA\n", "assert set(_wfm_d[\"region\"]) == set(DEFAULT_WFM_REGIONS)\n", "_approx(_wfm_d[\"benefit\"].sum(), 2_314_000) # $0 NA + $1.4M ANZ + $914K ASIA\n", "_by_d = _wfm_d.groupby(\"year\")[\"benefit\"].sum()\n", "_approx(_by_d[2026], 0)\n", "_approx(_by_d[2027], 1_400_000 / 13, tol=1) # ANZ: 1 of 13 live months in 2027\n", "_approx(wfm_annual_runrate(), 2_900_000)\n", "_approx(runrate_saving_annual(), 5_900_000) # deck frame: no managed services\n", "\n", "# Contracted licence overlay (tokencalc.appendix4.TCO_CONTRACTED)\n", "_approx(tco(\"ccaas_annual\"), 3_200_000) # signed correction\n", "_approx(TCO_VERBATIM[\"ccaas_annual\"], 4_300_000) # deck record stays verbatim\n", "_lc = licence_costs_by_year(annual=tco(\"ccaas_annual\"))\n", "_approx(_lc[2026], 1_600_000) # 6-mo ramp: half-year bills in 2026\n", "_approx(_lc[2027], 3_200_000)\n", "_approx(runrate_saving_annual(tco(\"ccaas_annual\"),\n", " managed_annual=MANAGED_SERVICES_ANNUAL),\n", " 6_589_081.60, tol=0.01)\n", "\n", "# Section 5 — default-flow pins (pure engine defaults, no widget state)\n", "_cur_d = current_costs_by_year(_seed_check)\n", "_ben_d = {y: float(_by_d.get(y, 0.0)) for y in YEARS}\n", "_tot_d = {y: _cur_d[y] + _l_deck[y] + ps_costs_by_year()[y] for y in YEARS}\n", "_inc_d, _net_d = case_flows(_tot_d, _ben_d)\n", "_approx(sum(_net_d.values()), -3_703_000, tol=1) # deck frame\n", "assert runrate_breakeven_label(_net_d, runrate_saving_annual()) == \\\n", " \"44 months (~Aug 2029, extrapolated)\"\n", "_tot_c = {y: _cur_d[y] + _lc[y] + _ps_c[y] + _ms_c[y] for y in YEARS}\n", "_inc_c, _net_c = case_flows(_tot_c, _ben_d)\n", "_approx(sum(_net_c.values()), -1_503_013, tol=1) # contracted frame — still negative\n", "assert runrate_breakeven_label(\n", " _net_c, runrate_saving_annual(tco(\"ccaas_annual\"),\n", " managed_annual=MANAGED_SERVICES_ANNUAL)) == \\\n", " \"39 months (~Mar 2029, extrapolated)\"\n", "\n", "# Live state — stack and flows tie out at whatever the widgets say\n", "_approx(sum(total_by_year.values()),\n", " sum(licence_by_year.values()) + sum(ps_by_year.values())\n", " + sum(managed_by_year.values()) + sum(current_by_year.values()))\n", "for y in YEARS:\n", " _approx(net_by[y], ben_by_year[y] - (total_by_year[y] - BASELINE_ANNUAL))\n", "\n", "backstage(\"All assertions passed.\")\n", "backstage(f\" WFM benefits 3-yr {money(sum(ben_by_year.values()))} · \"\n", " f\"programme cost 3-yr {money(sum(total_by_year.values()))} · \"\n", " f\"net 3-yr {money(kpi['net_3yr'])} · breakeven {BREAKEVEN}\")\n" ] }, { "cell_type": "markdown", "id": "67ac8561", "metadata": {}, "source": [ "\n", "\n", "## 8 · Risks, notes & next steps\n", "\n", "**Findings to lead with**\n", "- **The migration alone does not stand up inside the window, even at the signed rate**:\n", " with the full contracts in ($3.2M/yr licences, 6-month ramp, SOW milestones, managed\n", " services) the 3-yr net is ≈ −$1.5M, breaking even ~Mar 2029 on run-rate alone; the deck\n", " frame ($4.3M/yr, deck's PS lump) runs ≈ −$3.7M / ~Aug 2029. Keep both KPI columns in\n", " front of CTM — the walk from the deck's own numbers is the credibility story, and the\n", " post-termination run-rate is what carries the case. (With-AI case: the Corrected\n", " Business Case notebook.)\n", "- **2026 is a pure cost year** here too — PS outlay plus double-billing, zero benefits.\n", "- The ramp credit and the term-contract run-off cut in opposite directions; both are\n", " contract facts the deck's cost case omitted.\n", "\n", "**Known gaps / assumptions**\n", "- 🟡 Licence run-rate assumes WFM/WEM SKUs (incl. new APAC seats) are inside $4.3M/yr —\n", " if WEM is an add-on, raise the sidebar run-rate; section 6 shows the sensitivity.\n", "- 🟡 Regional current-cost split is an agent-share allocation pending real contract data;\n", " if NA's existing WFM tool bills separately, fold it into NA's section-1 input.\n", "- Deck quirk kept verbatim: ASIA WFM annual ($1.6M) exceeds its 3-yr value ($914K); the\n", " annual value feeds the run-rate extrapolation, the 3-yr value phases in-window.\n", "- EMEA WFM ($687K 3-yr) is an upside option — sidebar toggle.\n", "- 🟡 Training ($167K) is kept as the deck's verbatim year-1 line — the SOW milestones\n", " don't itemize it. Managed services are modelled with no end date inside the window.\n", "- Not modelled: early-termination fees, co-term options, migration costs beyond the SOW,\n", " and any benefit realization lag beyond Genesys's own schedule.\n", "\n", "**Next steps**\n", "- Feed real per-region contract values and termination dates into section 1.\n", "- Confirm in the order form that WFM/WEM SKUs and the APAC seats are inside the run-rate.\n", "- Pressure-test the base-price claim: does the $2.03M SOW really include the NA WFM\n", " migration and two net-new WFM implementations?" ] }, { "cell_type": "markdown", "id": "b8ee762f", "metadata": {}, "source": [ "\n", "\n", "## 9 · Machine-readable data appendix\n", "\n", "Every number behind the figures above, emitted as markdown tables plus a JSON block, so an\n", "LLM can draft the client report or presentation directly from the HTML/markdown export\n", "(`python scripts/export_report.py`). Plotly figures export as JavaScript an LLM cannot\n", "read — this section carries the data. The dump renders **backstage** (JupyterLab and the exports) and stays hidden in the Mercury app. The tables reflect the **current** input state, so a\n", "client-customized session exports a client-customized appendix." ] }, { "cell_type": "code", "execution_count": 17, "id": "32ebed57", "metadata": { "execution": { "iopub.execute_input": "2026-07-08T15:15:49.180044Z", "iopub.status.busy": "2026-07-08T15:15:49.179772Z", "iopub.status.idle": "2026-07-08T15:15:49.204642Z", "shell.execute_reply": "2026-07-08T15:15:49.203924Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "\n", "#### Current-state contracts by region (inputs)\n", "\n", "| region | agents | annual_cost | contract_termination | confidence |\n", "|:---------|---------:|--------------:|:-----------------------|:------------------------------------------------|\n", "| NA | 890 | 3,348,969 | 2027-12-31 | 🟡 agent-share allocation of the verbatim total |\n", "| ANZ | 180 | 677,320 | 2027-12-31 | 🟡 agent-share allocation of the verbatim total |\n", "| EMEA | 320 | 1,204,124 | 2027-12-31 | 🟡 agent-share allocation of the verbatim total |\n", "| ASIA | 550 | 2,069,588 | 2027-12-31 | 🟡 agent-share allocation of the verbatim total |\n", "\n", "#### WFM scope — treatment and verbatim values ($)\n", "\n", "| region | WFM treatment | in scope | verbatim annual | verbatim 3-yr |\n", "|:---------|:--------------------------|:-----------|------------------:|----------------:|\n", "| NA | migrate existing users | ✓ | 0 | 0 |\n", "| ANZ | implement (APAC) | ✓ | 1300000 | 1400000 |\n", "| EMEA | implement (upside option) | — | 824000 | 687000 |\n", "| ASIA | implement (APAC) | ✓ | 1600000 | 914000 |\n", "\n", "#### WFM benefits by region × year (schedule-phased, $)\n", "\n", "| region | 2026 | 2027 | 2028 |\n", "|:---------|-------:|--------:|----------:|\n", "| ANZ | 0 | 107,692 | 1,292,308 |\n", "| ASIA | 0 | 0 | 914,000 |\n", "| NA | 0 | 0 | 0 |\n", "\n", "#### Migration programme cost stack ($)\n", "\n", "| | 2026 | 2027 | 2028 | 3-yr |\n", "|:----------------------------------------------------|----------:|----------:|----------:|-----------:|\n", "| CCaaS platform licences (contracted, ramp-adjusted) | 1,600,000 | 3,200,000 | 3,200,000 | 8,000,000 |\n", "| NTT PS + training (SOW billing milestones) | 1,179,723 | 1,012,723 | 0 | 2,192,446 |\n", "| NTT managed services (from MCX go-live) | 102,730 | 410,918 | 410,918 | 924,566 |\n", "| Existing platform (term-contract run-off) | 7,300,001 | 7,300,001 | 0 | 14,600,002 |\n", "\n", "#### Business-case flows vs do-nothing baseline ($)\n", "\n", "| | 2026 | 2027 | 2028 |\n", "|:-----------------|-----------:|-----------:|-----------:|\n", "| programme cost | 10,182,454 | 11,923,643 | 3,610,918 |\n", "| incremental cost | 2,882,454 | 4,623,643 | -3,689,082 |\n", "| net | -2,882,454 | -4,515,950 | 5,895,389 |\n", "\n", "#### KPIs — migration + WFM (no AI)\n", "\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "| | Deck frame ($4.3M/yr, deck PS) | Contracted ($3.2M/yr + SOW) |\n", "|:-------------------------------------|:------------------------------------|:------------------------------------|\n", "| 3-yr WFM benefits (verbatim, scoped) | $2.3M | $2.3M |\n", "| 3-yr incremental cost | $6.0M | $3.8M |\n", "| 3-yr net | -$3.7M | -$1.5M |\n", "| ROI (net ÷ incremental cost) | -62% | -39% |\n", "| NPV @ 13.5% (deck rate) | -$3.8M | -$2.0M |\n", "| NPV @ 8.0% (CTM treasury) | -$3.8M | -$1.9M |\n", "| Payback (2026–28 window) | beyond 2028 | beyond 2028 |\n", "| Steady-state run-rate saving | $5.9M/yr | $6.6M/yr |\n", "| Breakeven | 44 months (~Aug 2029, extrapolated) | 39 months (~Mar 2029, extrapolated) |\n", "\n", "#### Model state (JSON)\n", "\n", "```json\n", "{\n", " \"scenario\": \"Genesys Cloud CX migration + WFM (NA migrate, APAC implement) \\u2014 no AI\",\n", " \"wfm_benefits_by_year\": {\n", " \"2026\": 0,\n", " \"2027\": 107692,\n", " \"2028\": 2206308\n", " },\n", " \"cost_by_year\": {\n", " \"2026\": 10182454,\n", " \"2027\": 11923643,\n", " \"2028\": 3610918\n", " },\n", " \"net_by_year\": {\n", " \"2026\": -2882454,\n", " \"2027\": -4515950,\n", " \"2028\": 5895389\n", " },\n", " \"kpis\": {\n", " \"benefits_3yr\": 2314000,\n", " \"incremental_cost_3yr\": 3817015,\n", " \"net_3yr\": -1503015,\n", " \"roi\": -0.3938,\n", " \"npv\": -2013126,\n", " \"discount_rate\": 0.135,\n", " \"payback\": \"beyond 2028\"\n", " },\n", " \"kpis_at_deck_rate\": {\n", " \"benefits_3yr\": 2314000,\n", " \"incremental_cost_3yr\": 6017002,\n", " \"net_3yr\": -3703002,\n", " \"roi\": -0.6154,\n", " \"npv\": -3849521,\n", " \"discount_rate\": 0.135,\n", " \"payback\": \"beyond 2028\"\n", " },\n", " \"runrate_saving_annual\": 6589082,\n", " \"breakeven\": \"39 months (~Mar 2029, extrapolated)\",\n", " \"assumptions\": {\n", " \"ramp_months\": 6,\n", " \"licence_annual\": 3200000,\n", " \"deck_ccaas_annual\": 4300000,\n", " \"ps_contracted_total\": 2025446.48,\n", " \"ps_milestones\": [\n", " {\n", " \"name\": \"SOW Effective Date\",\n", " \"date\": \"2026-03-15\",\n", " \"amount\": 607633.94\n", " },\n", " {\n", " \"name\": \"Start of client UAT (first region)\",\n", " \"date\": \"2026-09-30\",\n", " \"amount\": 405089.3\n", " },\n", " {\n", " \"name\": \"Start of client UAT (last region)\",\n", " \"date\": \"2027-06-30\",\n", " \"amount\": 607633.94\n", " },\n", " {\n", " \"name\": \"Completion of last go-live migration\",\n", " \"date\": \"2027-09-30\",\n", " \"amount\": 405089.3\n", " }\n", " ],\n", " \"managed_services_annual\": 410918.4,\n", " \"mcx_go_live\": \"2026-09-30\",\n", " \"licence_includes_wfm_skus\": true,\n", " \"wfm_regions\": [\n", " \"NA\",\n", " \"ANZ\",\n", " \"ASIA\"\n", " ],\n", " \"migration_and_wfm_in_base_price\": true,\n", " \"discount_rate\": 0.135,\n", " \"baseline_annual\": 7300000,\n", " \"current_state_by_region\": {\n", " \"NA\": {\n", " \"annual_cost\": 3348969,\n", " \"contract_termination\": \"2027-12-31\"\n", " },\n", " \"ANZ\": {\n", " \"annual_cost\": 677320,\n", " \"contract_termination\": \"2027-12-31\"\n", " },\n", " \"EMEA\": {\n", " \"annual_cost\": 1204124,\n", " \"contract_termination\": \"2027-12-31\"\n", " },\n", " \"ASIA\": {\n", " \"annual_cost\": 2069588,\n", " \"contract_termination\": \"2027-12-31\"\n", " }\n", " }\n", " }\n", "}\n", "```\n" ] } ], "source": [ "# ── Data appendix — LLM-readable dump of every model output ──────────\n", "# Renders backstage only (JupyterLab / nbconvert exports) — hidden on\n", "# the Mercury stage, where the narrative and figures carry the story.\n", "import json as _json\n", "\n", "\n", "def _section(title, df, **kw):\n", " backstage(f\"\\n#### {title}\\n\")\n", " backstage(df.to_markdown(floatfmt=\",.0f\", **kw))\n", "\n", "\n", "_section(\"Current-state contracts by region (inputs)\",\n", " current_state.reset_index().drop(columns=[\"share\"]), index=False)\n", "_section(\"WFM scope — treatment and verbatim values ($)\", wfm_scope_table)\n", "if not wfm_long.empty:\n", " _section(\"WFM benefits by region × year (schedule-phased, $)\",\n", " wfm_long.pivot_table(index=\"region\", columns=\"year\",\n", " values=\"benefit\", aggfunc=\"sum\"))\n", "_section(\"Migration programme cost stack ($)\", migration_costs)\n", "_section(\"Business-case flows vs do-nothing baseline ($)\",\n", " pd.DataFrame({\"programme cost\": total_by_year,\n", " \"incremental cost\": inc, \"net\": net_by}).T[YEARS])\n", "_section(\"KPIs — migration + WFM (no AI)\", kpis_fmt)\n", "\n", "\n", "def _jval(v):\n", " if isinstance(v, float):\n", " return round(v, 4) if abs(v) < 10 else round(v)\n", " return v\n", "\n", "\n", "backstage(\"\\n#### Model state (JSON)\\n\")\n", "backstage(\"```json\")\n", "backstage(_json.dumps({\n", " \"scenario\": \"Genesys Cloud CX migration + WFM (NA migrate, APAC implement) — no AI\",\n", " \"wfm_benefits_by_year\": {str(y): round(ben_by_year[y]) for y in YEARS},\n", " \"cost_by_year\": {str(y): round(total_by_year[y]) for y in YEARS},\n", " \"net_by_year\": {str(y): round(net_by[y]) for y in YEARS},\n", " \"kpis\": {k: _jval(v) for k, v in kpi.items()},\n", " \"kpis_at_deck_rate\": {k: _jval(v) for k, v in kpi_deck.items()},\n", " \"runrate_saving_annual\": round(RUNRATE_SAVING),\n", " \"breakeven\": BREAKEVEN,\n", " \"assumptions\": {\n", " \"ramp_months\": RAMP_MONTHS,\n", " \"licence_annual\": round(LICENCE_ANNUAL),\n", " \"deck_ccaas_annual\": round(TCO_VERBATIM[\"ccaas_annual\"]),\n", " \"ps_contracted_total\": round(PS_CONTRACTED_TOTAL, 2),\n", " \"ps_milestones\": [{\"name\": m[\"name\"], \"date\": m[\"date\"].isoformat(),\n", " \"amount\": m[\"amount\"]} for m in PS_MILESTONES],\n", " \"managed_services_annual\": MANAGED_SERVICES_ANNUAL,\n", " \"mcx_go_live\": MCX_GO_LIVE.isoformat(),\n", " \"licence_includes_wfm_skus\": True,\n", " \"wfm_regions\": WFM_REGIONS,\n", " \"migration_and_wfm_in_base_price\": True,\n", " \"discount_rate\": DISCOUNT_RATE,\n", " \"baseline_annual\": round(BASELINE_ANNUAL),\n", " \"current_state_by_region\": {\n", " r: {\"annual_cost\": round(float(current_state.loc[r, \"annual_cost\"])),\n", " \"contract_termination\": current_state.loc[r, \"contract_termination\"].isoformat()}\n", " for r in REGIONS},\n", " },\n", "}, indent=2))\n", "backstage(\"```\")" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.13.7" }, "widgets": { "application/vnd.jupyter.widget-state+json": { "state": { "098f9852a68245118ba3cb197da7c299": { "model_module": "@jupyter-widgets/base", "model_module_version": "2.0.0", "model_name": "LayoutModel", "state": { "_model_module": "@jupyter-widgets/base", "_model_module_version": "2.0.0", "_model_name": "LayoutModel", "_view_count": null, "_view_module": "@jupyter-widgets/base", "_view_module_version": "2.0.0", "_view_name": "LayoutView", "align_content": null, "align_items": null, "align_self": null, "border_bottom": null, "border_left": null, "border_right": null, "border_top": null, "bottom": null, "display": null, "flex": null, "flex_flow": null, "grid_area": null, "grid_auto_columns": null, "grid_auto_flow": null, "grid_auto_rows": null, "grid_column": null, "grid_gap": null, "grid_row": null, "grid_template_areas": null, "grid_template_columns": null, "grid_template_rows": null, "height": null, "justify_content": null, "justify_items": null, "left": null, "margin": null, "max_height": null, "max_width": null, "min_height": null, "min_width": null, "object_fit": null, "object_position": null, "order": null, "overflow": null, "padding": null, "right": null, "top": null, "visibility": null, "width": null } }, "0d71f3541aea48d29335b75aabae1a05": { "model_module": "@jupyter-widgets/base", "model_module_version": "2.0.0", "model_name": "LayoutModel", "state": { "_model_module": "@jupyter-widgets/base", "_model_module_version": "2.0.0", "_model_name": "LayoutModel", "_view_count": null, "_view_module": "@jupyter-widgets/base", "_view_module_version": "2.0.0", "_view_name": "LayoutView", "align_content": null, "align_items": null, "align_self": null, "border_bottom": null, "border_left": null, "border_right": null, "border_top": null, "bottom": null, "display": null, "flex": null, "flex_flow": null, "grid_area": null, "grid_auto_columns": null, "grid_auto_flow": null, "grid_auto_rows": null, "grid_column": null, "grid_gap": null, "grid_row": null, "grid_template_areas": null, "grid_template_columns": null, "grid_template_rows": null, "height": null, "justify_content": null, "justify_items": null, "left": null, "margin": null, "max_height": null, "max_width": null, "min_height": null, "min_width": null, "object_fit": null, "object_position": null, "order": null, "overflow": null, "padding": null, "right": null, "top": null, "visibility": null, "width": null } }, "1d4ddc81f2c249cf9be44e32e8213b85": { "model_module": "anywidget", "model_module_version": "~0.11.*", "model_name": "AnyModel", "state": { "_anywidget_id": "mercury.checkbox.CheckboxWidget", "_css": "\n .mljar-checkbox-container {\n display: inline-flex;\n align-items: center;\n gap: 10px;\n cursor: pointer;\n user-select: none;\n -webkit-tap-highlight-color: transparent;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n color: #0f172a;\n padding-left: 5px;\n }\n .mljar-checkbox-container.is-disabled {\n opacity: 0.6;\n cursor: not-allowed;\n }\n\n .mljar-checkbox-input {\n position: absolute;\n opacity: 0;\n width: 0;\n height: 0;\n }\n\n .mljar-checkbox-input:focus-visible + .mljar-checkbox-control {\n box-shadow: inset 0 0 0 2px #007bff;\n }\n\n .mljar-checkbox-label {\n font-size: 14px;\n line-height: 1.2;\n padding-top: 2px;\n }\n\n /* --- Toggle style (15% smaller) --- */\n .mljar-checkbox-container.is-toggle .mljar-checkbox-control {\n position: relative;\n width: 34px;\n height: 19px;\n border-radius: 999px;\n background: #f1f1f2;\n border: 1px solid #cfd1d5;\n transition: background 150ms ease, border-color 150ms ease;\n }\n .mljar-checkbox-container.is-toggle.is-checked .mljar-checkbox-control {\n background: #007bff;\n border-color: #007bff;\n }\n .mljar-checkbox-container.is-toggle .mljar-checkbox-control::after {\n content: \"\";\n position: absolute;\n top: 2px;\n left: 2px;\n width: 15px;\n height: 15px;\n border-radius: 50%;\n background: #ffffff;\n box-shadow: 0 1px 2px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.04);\n transition: transform 150ms ease;\n }\n .mljar-checkbox-container.is-toggle.is-checked .mljar-checkbox-control::after {\n transform: translateX(15px);\n }\n\n /* --- Classic box style --- */\n .mljar-checkbox-container.is-box .mljar-checkbox-control {\n width: 16px;\n height: 16px;\n border-radius: 4px;\n border: 1px solid #cfd1d5;\n background: #ffffff;\n display: inline-block;\n position: relative;\n }\n .mljar-checkbox-container.is-box.is-checked .mljar-checkbox-control {\n border-color: #007bff;\n background: #007bff;\n }\n .mljar-checkbox-container.is-box.is-checked .mljar-checkbox-control::after {\n content: \"\";\n position: absolute;\n left: 4px;\n top: 0px;\n width: 5px;\n height: 10px;\n border: solid #fff;\n border-width: 0 2px 2px 0;\n transform: rotate(45deg);\n }\n\n .mljar-checkbox-container:not(.is-disabled):hover .mljar-checkbox-control {\n border-color: #007bff;\n background: #f3f3f4;\n }\n ", "_dom_classes": [], "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"label\");\n container.classList.add(\"mljar-checkbox-container\");\n\n const input = document.createElement(\"input\");\n input.type = \"checkbox\";\n input.classList.add(\"mljar-checkbox-input\");\n\n const control = document.createElement(\"span\");\n control.classList.add(\"mljar-checkbox-control\");\n\n const text = document.createElement(\"span\");\n text.classList.add(\"mljar-checkbox-label\");\n\n container.appendChild(input);\n container.appendChild(control);\n container.appendChild(text);\n el.appendChild(container);\n\n function syncFromModel() {\n // appearance\n const a = model.get(\"appearance\") || \"toggle\";\n container.classList.remove(\"is-toggle\",\"is-box\");\n container.classList.add(`is-${a}`);\n input.setAttribute(\"role\", a === \"toggle\" ? \"switch\" : \"checkbox\");\n\n // value\n const v = !!model.get(\"value\");\n if (input.checked !== v) {\n input.checked = v;\n input.setAttribute(\"aria-checked\", String(v));\n }\n container.classList.toggle(\"is-checked\", v);\n\n // disabled\n const d = !!model.get(\"disabled\");\n input.disabled = d;\n container.classList.toggle(\"is-disabled\", d);\n\n // label\n text.textContent = model.get(\"label\") || \"\";\n\n // hidden (exists but not visible)\n container.style.display = model.get(\"hidden\") ? \"none\" : \"inline-flex\";\n }\n\n input.addEventListener(\"change\", () => {\n if (model.get(\"disabled\")) return;\n\n const v = input.checked;\n if (model.get(\"value\") !== v) {\n model.set(\"value\", v);\n model.set(\"last_changed_at\", new Date().toISOString());\n model.set(\"n_toggles\", (model.get(\"n_toggles\") || 0) + 1);\n model.save_changes();\n // model.send({ type: \"changed\", value: v });\n }\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:appearance\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n \n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }\n */\n }\n export default { render };\n ", "_model_module": "anywidget", "_model_module_version": "~0.11.*", "_model_name": "AnyModel", "_view_count": null, "_view_module": "anywidget", "_view_module_version": "~0.11.*", "_view_name": "AnyView", "appearance": "toggle", "cell_id": "", "disabled": false, "hidden": false, "label": "WFM — ASIA (implement)", "last_changed_at": "", "layout": "IPY_MODEL_b50136dc74fb4036b657d83d7b10c5e6", "layout_path": null, "n_toggles": 0, "position": "sidebar", "render_slot_id": null, "source_cell_id": null, "tabbable": null, "tooltip": null, "url_key": "", "value": true } }, "217923ff9f174ff0b1e46c612cb7cd44": { "model_module": "@jupyter-widgets/base", "model_module_version": "2.0.0", "model_name": "LayoutModel", "state": { "_model_module": "@jupyter-widgets/base", "_model_module_version": "2.0.0", "_model_name": "LayoutModel", "_view_count": null, "_view_module": "@jupyter-widgets/base", "_view_module_version": "2.0.0", "_view_name": "LayoutView", "align_content": null, "align_items": null, "align_self": null, "border_bottom": null, "border_left": null, "border_right": null, "border_top": null, "bottom": null, "display": null, "flex": null, "flex_flow": null, "grid_area": null, "grid_auto_columns": null, "grid_auto_flow": null, "grid_auto_rows": null, "grid_column": null, "grid_gap": null, "grid_row": null, "grid_template_areas": null, "grid_template_columns": null, "grid_template_rows": null, "height": null, "justify_content": null, "justify_items": null, "left": null, "margin": null, "max_height": null, "max_width": null, "min_height": null, "min_width": null, "object_fit": null, "object_position": null, "order": null, "overflow": null, "padding": null, "right": null, "top": null, "visibility": null, "width": null } }, "28eb2b2d2a2a41adaad830ac204d7b39": { "model_module": "anywidget", "model_module_version": "~0.11.*", "model_name": "AnyModel", "state": { "_anywidget_id": "mercury.number.NumberInputWidget", "_css": "\n .mljar-number-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-number-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-number-field-row {\n display: flex;\n align-items: stretch;\n width: 100%;\n min-height: 40px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n overflow: hidden;\n }\n\n .mljar-number-input {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 100%;\n padding: 7px 10px;\n border: 0;\n border-radius: 0;\n background: #ffffff;\n box-sizing: border-box;\n background-color: #ffffff !important;\n color: #0f172a !important;\n font: inherit;\n line-height: 1.2;\n -moz-appearance: textfield;\n }\n\n .mljar-number-input::-webkit-outer-spin-button,\n .mljar-number-input::-webkit-inner-spin-button {\n -webkit-appearance: none;\n margin: 0;\n }\n\n .mljar-number-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-number-input:focus {\n outline: none;\n }\n\n .mljar-number-field-row:focus-within {\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n }\n\n .mljar-number-field-row:focus-within .mljar-number-controls {\n border-left-color: #007bff;\n }\n\n .mljar-number-controls {\n display: flex;\n align-items: stretch;\n flex: 0 0 auto;\n border-left: 1px solid #cfd1d5;\n background: #f1f1f2;\n }\n\n .mljar-number-step-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 38px;\n min-width: 38px;\n min-height: 100%;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: #0f172a;\n font: inherit;\n font-size: 18px;\n font-weight: 700;\n line-height: 1;\n cursor: pointer;\n padding: 0;\n user-select: none;\n -webkit-user-select: none;\n touch-action: manipulation;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-number-step-up {\n border-left: 1px solid #cfd1d5;\n }\n\n .mljar-number-step-btn:hover {\n background: #f3f3f4;\n }\n\n .mljar-number-step-btn:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:focus-visible {\n outline: none;\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:disabled {\n background: #f5f5f5;\n color: #aaa;\n cursor: not-allowed;\n }\n\n @media (max-width: 768px) {\n .mljar-number-field-row {\n min-height: 44px;\n }\n\n .mljar-number-input {\n min-height: 44px;\n padding: 8px 12px;\n }\n\n .mljar-number-step-btn {\n font-size: 19px;\n width: 44px;\n min-width: 44px;\n }\n }\n ", "_dom_classes": [], "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-number-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-number-label\");\n\n const fieldRow = document.createElement(\"div\");\n fieldRow.classList.add(\"mljar-number-field-row\");\n\n const input = document.createElement(\"input\");\n input.type = \"number\";\n input.classList.add(\"mljar-number-input\");\n\n const decrementBtn = document.createElement(\"button\");\n decrementBtn.type = \"button\";\n decrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-down\");\n decrementBtn.textContent = \"-\";\n decrementBtn.setAttribute(\"aria-label\", \"Decrease value\");\n\n const incrementBtn = document.createElement(\"button\");\n incrementBtn.type = \"button\";\n incrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-up\");\n incrementBtn.textContent = \"+\";\n incrementBtn.setAttribute(\"aria-label\", \"Increase value\");\n\n const controls = document.createElement(\"div\");\n controls.classList.add(\"mljar-number-controls\");\n\n controls.appendChild(decrementBtn);\n controls.appendChild(incrementBtn);\n fieldRow.appendChild(input);\n fieldRow.appendChild(controls);\n\n container.appendChild(topLabel);\n container.appendChild(fieldRow);\n el.appendChild(container);\n\n function clamp(val, min, max) {\n if (Number.isFinite(min) && val < min) return min;\n if (Number.isFinite(max) && val > max) return max;\n return val;\n }\n\n function normalizeStep(step) {\n return Number.isFinite(step) && step > 0 ? step : 1;\n }\n\n function snapToStep(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = Math.round((value - base) / safeStep);\n const snapped = base + steps * safeStep;\n const precision = Math.max(\n 0,\n (String(safeStep).split(\".\")[1] || \"\").length\n );\n\n return Number(snapped.toFixed(precision + 2));\n }\n\n function isOnStepGrid(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = (value - base) / safeStep;\n const nearest = Math.round(steps);\n const epsilon = Math.max(1e-9, safeStep * 1e-9);\n\n return Math.abs(steps - nearest) <= epsilon;\n }\n\n function getCurrentBounds() {\n return {\n min: Number(model.get(\"min\")),\n max: Number(model.get(\"max\")),\n };\n }\n\n function isTransientDraft(raw) {\n return raw === \"\" || raw === \"-\" || raw === \".\" || raw === \"-.\";\n }\n\n let isEditing = false;\n const INPUT_COMMIT_DEBOUNCE_MS = 400;\n\n function clearPendingDraftCommit() {\n if (debounceTimer) clearTimeout(debounceTimer);\n pendingDraftValue = null;\n }\n\n function parseDraftValue(rawValue) {\n const raw = String(rawValue).trim();\n if (isTransientDraft(raw)) {\n return { kind: \"transient\" };\n }\n\n const value = Number(raw);\n if (!Number.isFinite(value)) {\n return { kind: \"invalid\" };\n }\n\n return { kind: \"number\", value };\n }\n\n function commitValue(nextValue, saveNow = true) {\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n const parsed = parseDraftValue(nextValue);\n if (parsed.kind !== \"number\") {\n syncFromModel();\n return;\n }\n\n let v = parsed.value;\n v = clamp(v, min, max);\n v = snapToStep(v, min, step);\n v = clamp(v, min, max);\n input.value = String(v);\n model.set(\"value\", v);\n\n if (saveNow) {\n model.save_changes();\n }\n }\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Enter number\";\n\n const min = Number(model.get(\"min\"));\n const max = Number(model.get(\"max\"));\n const step = Number(model.get(\"step\"));\n\n if (Number.isFinite(min)) input.min = String(min); else input.removeAttribute(\"min\");\n if (Number.isFinite(max)) input.max = String(max); else input.removeAttribute(\"max\");\n if (Number.isFinite(step)) input.step = String(step); else input.removeAttribute(\"step\");\n\n const v = Number(model.get(\"value\"));\n if (!isEditing) {\n input.value = Number.isFinite(v) ? String(v) : \"\";\n }\n\n const disabled = !!model.get(\"disabled\");\n input.disabled = disabled;\n incrementBtn.disabled = disabled;\n decrementBtn.disabled = disabled;\n\n const hidden = !!model.get(\"hidden\");\n container.style.display = hidden ? \"none\" : \"flex\";\n }\n\n let debounceTimer = null;\n let pendingDraftValue = null;\n input.addEventListener(\"focus\", () => {\n isEditing = true;\n });\n\n input.addEventListener(\"input\", () => {\n if (model.get(\"disabled\")) return;\n\n const parsed = parseDraftValue(input.value);\n if (parsed.kind !== \"number\") {\n clearPendingDraftCommit();\n return;\n }\n\n const v = parsed.value;\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n if (Number.isFinite(min) && v < min) {\n clearPendingDraftCommit();\n return;\n }\n if (Number.isFinite(max) && v > max) {\n clearPendingDraftCommit();\n return;\n }\n if (!isOnStepGrid(v, min, step)) {\n clearPendingDraftCommit();\n return;\n }\n\n pendingDraftValue = v;\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n if (pendingDraftValue === null) return;\n model.set(\"value\", pendingDraftValue);\n model.save_changes();\n pendingDraftValue = null;\n }, INPUT_COMMIT_DEBOUNCE_MS);\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n clearPendingDraftCommit();\n commitValue(input.value, true);\n });\n\n input.addEventListener(\"keydown\", event => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n input.blur();\n }\n });\n\n incrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base + step, min, step));\n });\n\n decrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base - step, min, step));\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:step\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }*/\n }\n export default { render };\n ", "_model_module": "anywidget", "_model_module_version": "~0.11.*", "_model_name": "AnyModel", "_view_count": null, "_view_module": "anywidget", "_view_module_version": "~0.11.*", "_view_name": "AnyView", "cell_id": "", "disabled": false, "hidden": false, "label": "ANZ — current platform cost ($/yr)", "layout": "IPY_MODEL_343cc107bc7b4369a4e654d5a2a888b1", "layout_path": null, "max": 8000000.0, "min": 0.0, "position": "inline", "render_slot_id": null, "source_cell_id": null, "step": 10000.0, "tabbable": null, "tooltip": null, "url_key": "", "value": 677320.0 } }, "2be8dde4d1644691add8dae88a36ac81": { "model_module": "anywidget", "model_module_version": "~0.11.*", "model_name": "AnyModel", "state": { "_anywidget_id": "mercury.checkbox.CheckboxWidget", "_css": "\n .mljar-checkbox-container {\n display: inline-flex;\n align-items: center;\n gap: 10px;\n cursor: pointer;\n user-select: none;\n -webkit-tap-highlight-color: transparent;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n color: #0f172a;\n padding-left: 5px;\n }\n .mljar-checkbox-container.is-disabled {\n opacity: 0.6;\n cursor: not-allowed;\n }\n\n .mljar-checkbox-input {\n position: absolute;\n opacity: 0;\n width: 0;\n height: 0;\n }\n\n .mljar-checkbox-input:focus-visible + .mljar-checkbox-control {\n box-shadow: inset 0 0 0 2px #007bff;\n }\n\n .mljar-checkbox-label {\n font-size: 14px;\n line-height: 1.2;\n padding-top: 2px;\n }\n\n /* --- Toggle style (15% smaller) --- */\n .mljar-checkbox-container.is-toggle .mljar-checkbox-control {\n position: relative;\n width: 34px;\n height: 19px;\n border-radius: 999px;\n background: #f1f1f2;\n border: 1px solid #cfd1d5;\n transition: background 150ms ease, border-color 150ms ease;\n }\n .mljar-checkbox-container.is-toggle.is-checked .mljar-checkbox-control {\n background: #007bff;\n border-color: #007bff;\n }\n .mljar-checkbox-container.is-toggle .mljar-checkbox-control::after {\n content: \"\";\n position: absolute;\n top: 2px;\n left: 2px;\n width: 15px;\n height: 15px;\n border-radius: 50%;\n background: #ffffff;\n box-shadow: 0 1px 2px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.04);\n transition: transform 150ms ease;\n }\n .mljar-checkbox-container.is-toggle.is-checked .mljar-checkbox-control::after {\n transform: translateX(15px);\n }\n\n /* --- Classic box style --- */\n .mljar-checkbox-container.is-box .mljar-checkbox-control {\n width: 16px;\n height: 16px;\n border-radius: 4px;\n border: 1px solid #cfd1d5;\n background: #ffffff;\n display: inline-block;\n position: relative;\n }\n .mljar-checkbox-container.is-box.is-checked .mljar-checkbox-control {\n border-color: #007bff;\n background: #007bff;\n }\n .mljar-checkbox-container.is-box.is-checked .mljar-checkbox-control::after {\n content: \"\";\n position: absolute;\n left: 4px;\n top: 0px;\n width: 5px;\n height: 10px;\n border: solid #fff;\n border-width: 0 2px 2px 0;\n transform: rotate(45deg);\n }\n\n .mljar-checkbox-container:not(.is-disabled):hover .mljar-checkbox-control {\n border-color: #007bff;\n background: #f3f3f4;\n }\n ", "_dom_classes": [], "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"label\");\n container.classList.add(\"mljar-checkbox-container\");\n\n const input = document.createElement(\"input\");\n input.type = \"checkbox\";\n input.classList.add(\"mljar-checkbox-input\");\n\n const control = document.createElement(\"span\");\n control.classList.add(\"mljar-checkbox-control\");\n\n const text = document.createElement(\"span\");\n text.classList.add(\"mljar-checkbox-label\");\n\n container.appendChild(input);\n container.appendChild(control);\n container.appendChild(text);\n el.appendChild(container);\n\n function syncFromModel() {\n // appearance\n const a = model.get(\"appearance\") || \"toggle\";\n container.classList.remove(\"is-toggle\",\"is-box\");\n container.classList.add(`is-${a}`);\n input.setAttribute(\"role\", a === \"toggle\" ? \"switch\" : \"checkbox\");\n\n // value\n const v = !!model.get(\"value\");\n if (input.checked !== v) {\n input.checked = v;\n input.setAttribute(\"aria-checked\", String(v));\n }\n container.classList.toggle(\"is-checked\", v);\n\n // disabled\n const d = !!model.get(\"disabled\");\n input.disabled = d;\n container.classList.toggle(\"is-disabled\", d);\n\n // label\n text.textContent = model.get(\"label\") || \"\";\n\n // hidden (exists but not visible)\n container.style.display = model.get(\"hidden\") ? \"none\" : \"inline-flex\";\n }\n\n input.addEventListener(\"change\", () => {\n if (model.get(\"disabled\")) return;\n\n const v = input.checked;\n if (model.get(\"value\") !== v) {\n model.set(\"value\", v);\n model.set(\"last_changed_at\", new Date().toISOString());\n model.set(\"n_toggles\", (model.get(\"n_toggles\") || 0) + 1);\n model.save_changes();\n // model.send({ type: \"changed\", value: v });\n }\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:appearance\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n \n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }\n */\n }\n export default { render };\n ", "_model_module": "anywidget", "_model_module_version": "~0.11.*", "_model_name": "AnyModel", "_view_count": null, "_view_module": "anywidget", "_view_module_version": "~0.11.*", "_view_name": "AnyView", "appearance": "toggle", "cell_id": "", "disabled": false, "hidden": false, "label": "WFM — NA (migrate existing users)", "last_changed_at": "", "layout": "IPY_MODEL_d9bccb3d954a4ad88eca96950703a866", "layout_path": null, "n_toggles": 0, "position": "sidebar", "render_slot_id": null, "source_cell_id": null, "tabbable": null, "tooltip": null, "url_key": "", "value": true } }, "343cc107bc7b4369a4e654d5a2a888b1": { "model_module": "@jupyter-widgets/base", "model_module_version": "2.0.0", "model_name": "LayoutModel", "state": { "_model_module": "@jupyter-widgets/base", "_model_module_version": "2.0.0", "_model_name": "LayoutModel", "_view_count": null, "_view_module": "@jupyter-widgets/base", "_view_module_version": "2.0.0", "_view_name": "LayoutView", "align_content": null, "align_items": null, "align_self": null, "border_bottom": null, "border_left": null, "border_right": null, "border_top": null, "bottom": null, "display": null, "flex": null, "flex_flow": null, "grid_area": null, "grid_auto_columns": null, "grid_auto_flow": null, "grid_auto_rows": null, "grid_column": null, "grid_gap": null, "grid_row": null, "grid_template_areas": null, "grid_template_columns": null, "grid_template_rows": null, "height": null, "justify_content": null, "justify_items": null, "left": null, "margin": null, "max_height": null, "max_width": null, "min_height": null, "min_width": null, "object_fit": null, "object_position": null, "order": null, "overflow": null, "padding": null, "right": null, "top": null, "visibility": null, "width": null } }, "36fd828f151b4a7ba019f601b7a2099e": { "model_module": "@jupyter-widgets/base", "model_module_version": "2.0.0", "model_name": "LayoutModel", "state": { "_model_module": "@jupyter-widgets/base", "_model_module_version": "2.0.0", "_model_name": "LayoutModel", "_view_count": null, "_view_module": "@jupyter-widgets/base", "_view_module_version": "2.0.0", "_view_name": "LayoutView", "align_content": null, "align_items": null, "align_self": null, "border_bottom": null, "border_left": null, "border_right": null, "border_top": null, "bottom": null, "display": null, "flex": null, "flex_flow": null, "grid_area": null, "grid_auto_columns": null, "grid_auto_flow": null, "grid_auto_rows": null, "grid_column": null, "grid_gap": null, "grid_row": null, "grid_template_areas": null, "grid_template_columns": null, "grid_template_rows": null, "height": null, "justify_content": null, "justify_items": null, "left": null, "margin": null, "max_height": null, "max_width": null, "min_height": null, "min_width": null, "object_fit": null, "object_position": null, "order": null, "overflow": null, "padding": null, "right": null, "top": null, "visibility": null, "width": null } }, "3d24b7abdffd4cb580cf7a42348d1cd0": { "model_module": "@jupyter-widgets/base", "model_module_version": "2.0.0", "model_name": "LayoutModel", "state": { "_model_module": "@jupyter-widgets/base", "_model_module_version": "2.0.0", "_model_name": "LayoutModel", "_view_count": null, "_view_module": "@jupyter-widgets/base", "_view_module_version": "2.0.0", "_view_name": "LayoutView", "align_content": null, "align_items": null, "align_self": null, "border_bottom": null, "border_left": null, "border_right": null, "border_top": null, "bottom": null, "display": null, "flex": null, "flex_flow": null, "grid_area": null, "grid_auto_columns": null, "grid_auto_flow": null, "grid_auto_rows": null, "grid_column": null, "grid_gap": null, "grid_row": null, "grid_template_areas": null, "grid_template_columns": null, "grid_template_rows": null, "height": null, "justify_content": null, "justify_items": null, "left": null, "margin": null, "max_height": null, "max_width": null, "min_height": null, "min_width": null, "object_fit": null, "object_position": null, "order": null, "overflow": null, "padding": null, "right": null, "top": null, "visibility": null, "width": null } }, "423a009e5b26436c9ccdb69e031722a4": { "model_module": "@jupyter-widgets/base", "model_module_version": "2.0.0", "model_name": "LayoutModel", "state": { "_model_module": "@jupyter-widgets/base", "_model_module_version": "2.0.0", "_model_name": "LayoutModel", "_view_count": null, "_view_module": "@jupyter-widgets/base", "_view_module_version": "2.0.0", "_view_name": "LayoutView", "align_content": null, "align_items": null, "align_self": null, "border_bottom": null, "border_left": null, "border_right": null, "border_top": null, "bottom": null, "display": null, "flex": null, "flex_flow": null, "grid_area": null, "grid_auto_columns": null, "grid_auto_flow": null, "grid_auto_rows": null, "grid_column": null, "grid_gap": null, "grid_row": null, "grid_template_areas": null, "grid_template_columns": null, "grid_template_rows": null, "height": null, "justify_content": null, "justify_items": null, "left": null, "margin": null, "max_height": null, "max_width": null, "min_height": null, "min_width": null, "object_fit": null, "object_position": null, "order": null, "overflow": null, "padding": null, "right": null, "top": null, "visibility": null, "width": null } }, "4d3b2b0c854b4bcab393ef4f89aee1b4": { "model_module": "anywidget", "model_module_version": "~0.11.*", "model_name": "AnyModel", "state": { "_anywidget_id": "mercury.date.DateInputWidget", "_css": "\n .mljar-date-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-date-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-date-input {\n width: 100%;\n min-height: 40px;\n padding: 9px 10px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n color: #0f172a;\n box-sizing: border-box;\n line-height: 1.4;\n }\n\n .mljar-date-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-date-input:focus {\n outline: none;\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n }\n\n @media (max-width: 768px) {\n .mljar-date-input {\n min-height: 44px;\n padding: 10px 12px;\n }\n }\n ", "_dom_classes": [], "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-date-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-date-label\");\n\n const input = document.createElement(\"input\");\n input.type = \"date\";\n input.classList.add(\"mljar-date-input\");\n\n container.appendChild(topLabel);\n container.appendChild(input);\n el.appendChild(container);\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Date\";\n input.value = model.get(\"value\") || \"\";\n\n const min = model.get(\"min\") || \"\";\n const max = model.get(\"max\") || \"\";\n if (min) input.min = min; else input.removeAttribute(\"min\");\n if (max) input.max = max; else input.removeAttribute(\"max\");\n\n input.disabled = !!model.get(\"disabled\");\n container.style.display = model.get(\"hidden\") ? \"none\" : \"flex\";\n }\n\n input.addEventListener(\"change\", () => {\n if (model.get(\"disabled\")) return;\n model.set(\"value\", input.value);\n model.save_changes();\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n }\n export default { render };\n ", "_model_module": "anywidget", "_model_module_version": "~0.11.*", "_model_name": "AnyModel", "_view_count": null, "_view_module": "anywidget", "_view_module_version": "~0.11.*", "_view_name": "AnyView", "cell_id": "", "disabled": false, "hidden": false, "label": "ANZ — contract termination", "layout": "IPY_MODEL_3d24b7abdffd4cb580cf7a42348d1cd0", "layout_path": null, "max": "", "min": "", "position": "inline", "render_slot_id": null, "source_cell_id": null, "tabbable": null, "tooltip": null, "url_key": "", "value": "2027-12-31" } }, "58dab83fd1e24bd8b609d9fe3fd8420c": { "model_module": "@jupyter-widgets/base", "model_module_version": "2.0.0", "model_name": "LayoutModel", "state": { "_model_module": "@jupyter-widgets/base", "_model_module_version": "2.0.0", "_model_name": "LayoutModel", "_view_count": null, "_view_module": "@jupyter-widgets/base", "_view_module_version": "2.0.0", "_view_name": "LayoutView", "align_content": null, "align_items": null, "align_self": null, "border_bottom": null, "border_left": null, "border_right": null, "border_top": null, "bottom": null, "display": null, "flex": null, "flex_flow": null, "grid_area": null, "grid_auto_columns": null, "grid_auto_flow": null, "grid_auto_rows": null, "grid_column": null, "grid_gap": null, "grid_row": null, "grid_template_areas": null, "grid_template_columns": null, "grid_template_rows": null, "height": null, "justify_content": null, "justify_items": null, "left": null, "margin": null, "max_height": null, "max_width": null, "min_height": null, "min_width": null, "object_fit": null, "object_position": null, "order": null, "overflow": null, "padding": null, "right": null, "top": null, "visibility": null, "width": null } }, "5c47680e253c4f45bbbbfcc76612499b": { "model_module": "anywidget", "model_module_version": "~0.11.*", "model_name": "AnyModel", "state": { "_anywidget_id": "mercury.checkbox.CheckboxWidget", "_css": "\n .mljar-checkbox-container {\n display: inline-flex;\n align-items: center;\n gap: 10px;\n cursor: pointer;\n user-select: none;\n -webkit-tap-highlight-color: transparent;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n color: #0f172a;\n padding-left: 5px;\n }\n .mljar-checkbox-container.is-disabled {\n opacity: 0.6;\n cursor: not-allowed;\n }\n\n .mljar-checkbox-input {\n position: absolute;\n opacity: 0;\n width: 0;\n height: 0;\n }\n\n .mljar-checkbox-input:focus-visible + .mljar-checkbox-control {\n box-shadow: inset 0 0 0 2px #007bff;\n }\n\n .mljar-checkbox-label {\n font-size: 14px;\n line-height: 1.2;\n padding-top: 2px;\n }\n\n /* --- Toggle style (15% smaller) --- */\n .mljar-checkbox-container.is-toggle .mljar-checkbox-control {\n position: relative;\n width: 34px;\n height: 19px;\n border-radius: 999px;\n background: #f1f1f2;\n border: 1px solid #cfd1d5;\n transition: background 150ms ease, border-color 150ms ease;\n }\n .mljar-checkbox-container.is-toggle.is-checked .mljar-checkbox-control {\n background: #007bff;\n border-color: #007bff;\n }\n .mljar-checkbox-container.is-toggle .mljar-checkbox-control::after {\n content: \"\";\n position: absolute;\n top: 2px;\n left: 2px;\n width: 15px;\n height: 15px;\n border-radius: 50%;\n background: #ffffff;\n box-shadow: 0 1px 2px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.04);\n transition: transform 150ms ease;\n }\n .mljar-checkbox-container.is-toggle.is-checked .mljar-checkbox-control::after {\n transform: translateX(15px);\n }\n\n /* --- Classic box style --- */\n .mljar-checkbox-container.is-box .mljar-checkbox-control {\n width: 16px;\n height: 16px;\n border-radius: 4px;\n border: 1px solid #cfd1d5;\n background: #ffffff;\n display: inline-block;\n position: relative;\n }\n .mljar-checkbox-container.is-box.is-checked .mljar-checkbox-control {\n border-color: #007bff;\n background: #007bff;\n }\n .mljar-checkbox-container.is-box.is-checked .mljar-checkbox-control::after {\n content: \"\";\n position: absolute;\n left: 4px;\n top: 0px;\n width: 5px;\n height: 10px;\n border: solid #fff;\n border-width: 0 2px 2px 0;\n transform: rotate(45deg);\n }\n\n .mljar-checkbox-container:not(.is-disabled):hover .mljar-checkbox-control {\n border-color: #007bff;\n background: #f3f3f4;\n }\n ", "_dom_classes": [], "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"label\");\n container.classList.add(\"mljar-checkbox-container\");\n\n const input = document.createElement(\"input\");\n input.type = \"checkbox\";\n input.classList.add(\"mljar-checkbox-input\");\n\n const control = document.createElement(\"span\");\n control.classList.add(\"mljar-checkbox-control\");\n\n const text = document.createElement(\"span\");\n text.classList.add(\"mljar-checkbox-label\");\n\n container.appendChild(input);\n container.appendChild(control);\n container.appendChild(text);\n el.appendChild(container);\n\n function syncFromModel() {\n // appearance\n const a = model.get(\"appearance\") || \"toggle\";\n container.classList.remove(\"is-toggle\",\"is-box\");\n container.classList.add(`is-${a}`);\n input.setAttribute(\"role\", a === \"toggle\" ? \"switch\" : \"checkbox\");\n\n // value\n const v = !!model.get(\"value\");\n if (input.checked !== v) {\n input.checked = v;\n input.setAttribute(\"aria-checked\", String(v));\n }\n container.classList.toggle(\"is-checked\", v);\n\n // disabled\n const d = !!model.get(\"disabled\");\n input.disabled = d;\n container.classList.toggle(\"is-disabled\", d);\n\n // label\n text.textContent = model.get(\"label\") || \"\";\n\n // hidden (exists but not visible)\n container.style.display = model.get(\"hidden\") ? \"none\" : \"inline-flex\";\n }\n\n input.addEventListener(\"change\", () => {\n if (model.get(\"disabled\")) return;\n\n const v = input.checked;\n if (model.get(\"value\") !== v) {\n model.set(\"value\", v);\n model.set(\"last_changed_at\", new Date().toISOString());\n model.set(\"n_toggles\", (model.get(\"n_toggles\") || 0) + 1);\n model.save_changes();\n // model.send({ type: \"changed\", value: v });\n }\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:appearance\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n \n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }\n */\n }\n export default { render };\n ", "_model_module": "anywidget", "_model_module_version": "~0.11.*", "_model_name": "AnyModel", "_view_count": null, "_view_module": "anywidget", "_view_module_version": "~0.11.*", "_view_name": "AnyView", "appearance": "toggle", "cell_id": "", "disabled": false, "hidden": false, "label": "WFM — EMEA (upside option)", "last_changed_at": "", "layout": "IPY_MODEL_217923ff9f174ff0b1e46c612cb7cd44", "layout_path": null, "n_toggles": 0, "position": "sidebar", "render_slot_id": null, "source_cell_id": null, "tabbable": null, "tooltip": null, "url_key": "", "value": false } }, "68b411c315ff4357815d7ff9801f445a": { "model_module": "anywidget", "model_module_version": "~0.11.*", "model_name": "AnyModel", "state": { "_anywidget_id": "mercury.date.DateInputWidget", "_css": "\n .mljar-date-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-date-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-date-input {\n width: 100%;\n min-height: 40px;\n padding: 9px 10px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n color: #0f172a;\n box-sizing: border-box;\n line-height: 1.4;\n }\n\n .mljar-date-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-date-input:focus {\n outline: none;\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n }\n\n @media (max-width: 768px) {\n .mljar-date-input {\n min-height: 44px;\n padding: 10px 12px;\n }\n }\n ", "_dom_classes": [], "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-date-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-date-label\");\n\n const input = document.createElement(\"input\");\n input.type = \"date\";\n input.classList.add(\"mljar-date-input\");\n\n container.appendChild(topLabel);\n container.appendChild(input);\n el.appendChild(container);\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Date\";\n input.value = model.get(\"value\") || \"\";\n\n const min = model.get(\"min\") || \"\";\n const max = model.get(\"max\") || \"\";\n if (min) input.min = min; else input.removeAttribute(\"min\");\n if (max) input.max = max; else input.removeAttribute(\"max\");\n\n input.disabled = !!model.get(\"disabled\");\n container.style.display = model.get(\"hidden\") ? \"none\" : \"flex\";\n }\n\n input.addEventListener(\"change\", () => {\n if (model.get(\"disabled\")) return;\n model.set(\"value\", input.value);\n model.save_changes();\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n }\n export default { render };\n ", "_model_module": "anywidget", "_model_module_version": "~0.11.*", "_model_name": "AnyModel", "_view_count": null, "_view_module": "anywidget", "_view_module_version": "~0.11.*", "_view_name": "AnyView", "cell_id": "", "disabled": false, "hidden": false, "label": "ASIA — contract termination", "layout": "IPY_MODEL_36fd828f151b4a7ba019f601b7a2099e", "layout_path": null, "max": "", "min": "", "position": "inline", "render_slot_id": null, "source_cell_id": null, "tabbable": null, "tooltip": null, "url_key": "", "value": "2027-12-31" } }, "6bc9b75143f244bbbb3a29cdbcb95e53": { "model_module": "@jupyter-widgets/base", "model_module_version": "2.0.0", "model_name": "LayoutModel", "state": { "_model_module": "@jupyter-widgets/base", "_model_module_version": "2.0.0", "_model_name": "LayoutModel", "_view_count": null, "_view_module": "@jupyter-widgets/base", "_view_module_version": "2.0.0", "_view_name": "LayoutView", "align_content": null, "align_items": null, "align_self": null, "border_bottom": null, "border_left": null, "border_right": null, "border_top": null, "bottom": null, "display": null, "flex": null, "flex_flow": null, "grid_area": null, "grid_auto_columns": null, "grid_auto_flow": null, "grid_auto_rows": null, "grid_column": null, "grid_gap": null, "grid_row": null, "grid_template_areas": null, "grid_template_columns": null, "grid_template_rows": null, "height": null, "justify_content": null, "justify_items": null, "left": null, "margin": null, "max_height": null, "max_width": null, "min_height": null, "min_width": null, "object_fit": null, "object_position": null, "order": null, "overflow": null, "padding": null, "right": null, "top": null, "visibility": null, "width": null } }, "6d231d033d7b4f41b852046f069ebc3f": { "model_module": "anywidget", "model_module_version": "~0.11.*", "model_name": "AnyModel", "state": { "_anywidget_id": "mercury.number.NumberInputWidget", "_css": "\n .mljar-number-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-number-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-number-field-row {\n display: flex;\n align-items: stretch;\n width: 100%;\n min-height: 40px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n overflow: hidden;\n }\n\n .mljar-number-input {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 100%;\n padding: 7px 10px;\n border: 0;\n border-radius: 0;\n background: #ffffff;\n box-sizing: border-box;\n background-color: #ffffff !important;\n color: #0f172a !important;\n font: inherit;\n line-height: 1.2;\n -moz-appearance: textfield;\n }\n\n .mljar-number-input::-webkit-outer-spin-button,\n .mljar-number-input::-webkit-inner-spin-button {\n -webkit-appearance: none;\n margin: 0;\n }\n\n .mljar-number-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-number-input:focus {\n outline: none;\n }\n\n .mljar-number-field-row:focus-within {\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n }\n\n .mljar-number-field-row:focus-within .mljar-number-controls {\n border-left-color: #007bff;\n }\n\n .mljar-number-controls {\n display: flex;\n align-items: stretch;\n flex: 0 0 auto;\n border-left: 1px solid #cfd1d5;\n background: #f1f1f2;\n }\n\n .mljar-number-step-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 38px;\n min-width: 38px;\n min-height: 100%;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: #0f172a;\n font: inherit;\n font-size: 18px;\n font-weight: 700;\n line-height: 1;\n cursor: pointer;\n padding: 0;\n user-select: none;\n -webkit-user-select: none;\n touch-action: manipulation;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-number-step-up {\n border-left: 1px solid #cfd1d5;\n }\n\n .mljar-number-step-btn:hover {\n background: #f3f3f4;\n }\n\n .mljar-number-step-btn:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:focus-visible {\n outline: none;\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:disabled {\n background: #f5f5f5;\n color: #aaa;\n cursor: not-allowed;\n }\n\n @media (max-width: 768px) {\n .mljar-number-field-row {\n min-height: 44px;\n }\n\n .mljar-number-input {\n min-height: 44px;\n padding: 8px 12px;\n }\n\n .mljar-number-step-btn {\n font-size: 19px;\n width: 44px;\n min-width: 44px;\n }\n }\n ", "_dom_classes": [], "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-number-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-number-label\");\n\n const fieldRow = document.createElement(\"div\");\n fieldRow.classList.add(\"mljar-number-field-row\");\n\n const input = document.createElement(\"input\");\n input.type = \"number\";\n input.classList.add(\"mljar-number-input\");\n\n const decrementBtn = document.createElement(\"button\");\n decrementBtn.type = \"button\";\n decrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-down\");\n decrementBtn.textContent = \"-\";\n decrementBtn.setAttribute(\"aria-label\", \"Decrease value\");\n\n const incrementBtn = document.createElement(\"button\");\n incrementBtn.type = \"button\";\n incrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-up\");\n incrementBtn.textContent = \"+\";\n incrementBtn.setAttribute(\"aria-label\", \"Increase value\");\n\n const controls = document.createElement(\"div\");\n controls.classList.add(\"mljar-number-controls\");\n\n controls.appendChild(decrementBtn);\n controls.appendChild(incrementBtn);\n fieldRow.appendChild(input);\n fieldRow.appendChild(controls);\n\n container.appendChild(topLabel);\n container.appendChild(fieldRow);\n el.appendChild(container);\n\n function clamp(val, min, max) {\n if (Number.isFinite(min) && val < min) return min;\n if (Number.isFinite(max) && val > max) return max;\n return val;\n }\n\n function normalizeStep(step) {\n return Number.isFinite(step) && step > 0 ? step : 1;\n }\n\n function snapToStep(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = Math.round((value - base) / safeStep);\n const snapped = base + steps * safeStep;\n const precision = Math.max(\n 0,\n (String(safeStep).split(\".\")[1] || \"\").length\n );\n\n return Number(snapped.toFixed(precision + 2));\n }\n\n function isOnStepGrid(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = (value - base) / safeStep;\n const nearest = Math.round(steps);\n const epsilon = Math.max(1e-9, safeStep * 1e-9);\n\n return Math.abs(steps - nearest) <= epsilon;\n }\n\n function getCurrentBounds() {\n return {\n min: Number(model.get(\"min\")),\n max: Number(model.get(\"max\")),\n };\n }\n\n function isTransientDraft(raw) {\n return raw === \"\" || raw === \"-\" || raw === \".\" || raw === \"-.\";\n }\n\n let isEditing = false;\n const INPUT_COMMIT_DEBOUNCE_MS = 400;\n\n function clearPendingDraftCommit() {\n if (debounceTimer) clearTimeout(debounceTimer);\n pendingDraftValue = null;\n }\n\n function parseDraftValue(rawValue) {\n const raw = String(rawValue).trim();\n if (isTransientDraft(raw)) {\n return { kind: \"transient\" };\n }\n\n const value = Number(raw);\n if (!Number.isFinite(value)) {\n return { kind: \"invalid\" };\n }\n\n return { kind: \"number\", value };\n }\n\n function commitValue(nextValue, saveNow = true) {\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n const parsed = parseDraftValue(nextValue);\n if (parsed.kind !== \"number\") {\n syncFromModel();\n return;\n }\n\n let v = parsed.value;\n v = clamp(v, min, max);\n v = snapToStep(v, min, step);\n v = clamp(v, min, max);\n input.value = String(v);\n model.set(\"value\", v);\n\n if (saveNow) {\n model.save_changes();\n }\n }\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Enter number\";\n\n const min = Number(model.get(\"min\"));\n const max = Number(model.get(\"max\"));\n const step = Number(model.get(\"step\"));\n\n if (Number.isFinite(min)) input.min = String(min); else input.removeAttribute(\"min\");\n if (Number.isFinite(max)) input.max = String(max); else input.removeAttribute(\"max\");\n if (Number.isFinite(step)) input.step = String(step); else input.removeAttribute(\"step\");\n\n const v = Number(model.get(\"value\"));\n if (!isEditing) {\n input.value = Number.isFinite(v) ? String(v) : \"\";\n }\n\n const disabled = !!model.get(\"disabled\");\n input.disabled = disabled;\n incrementBtn.disabled = disabled;\n decrementBtn.disabled = disabled;\n\n const hidden = !!model.get(\"hidden\");\n container.style.display = hidden ? \"none\" : \"flex\";\n }\n\n let debounceTimer = null;\n let pendingDraftValue = null;\n input.addEventListener(\"focus\", () => {\n isEditing = true;\n });\n\n input.addEventListener(\"input\", () => {\n if (model.get(\"disabled\")) return;\n\n const parsed = parseDraftValue(input.value);\n if (parsed.kind !== \"number\") {\n clearPendingDraftCommit();\n return;\n }\n\n const v = parsed.value;\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n if (Number.isFinite(min) && v < min) {\n clearPendingDraftCommit();\n return;\n }\n if (Number.isFinite(max) && v > max) {\n clearPendingDraftCommit();\n return;\n }\n if (!isOnStepGrid(v, min, step)) {\n clearPendingDraftCommit();\n return;\n }\n\n pendingDraftValue = v;\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n if (pendingDraftValue === null) return;\n model.set(\"value\", pendingDraftValue);\n model.save_changes();\n pendingDraftValue = null;\n }, INPUT_COMMIT_DEBOUNCE_MS);\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n clearPendingDraftCommit();\n commitValue(input.value, true);\n });\n\n input.addEventListener(\"keydown\", event => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n input.blur();\n }\n });\n\n incrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base + step, min, step));\n });\n\n decrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base - step, min, step));\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:step\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }*/\n }\n export default { render };\n ", "_model_module": "anywidget", "_model_module_version": "~0.11.*", "_model_name": "AnyModel", "_view_count": null, "_view_module": "anywidget", "_view_module_version": "~0.11.*", "_view_name": "AnyView", "cell_id": "", "disabled": false, "hidden": false, "label": "NA — current platform cost ($/yr)", "layout": "IPY_MODEL_58dab83fd1e24bd8b609d9fe3fd8420c", "layout_path": null, "max": 8000000.0, "min": 0.0, "position": "inline", "render_slot_id": null, "source_cell_id": null, "step": 10000.0, "tabbable": null, "tooltip": null, "url_key": "", "value": 3348969.0 } }, "720f723fcedb42efa4441ea0f5e2aae2": { "model_module": "anywidget", "model_module_version": "~0.11.*", "model_name": "AnyModel", "state": { "_anywidget_id": "mercury.select.SelectWidget", "_css": "\n .mljar-select-container {\n display: flex;\n flex-direction: column;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n }\n\n .mljar-select-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-select-control {\n position: relative;\n display: flex;\n align-items: center;\n cursor: default;\n }\n\n .mljar-select-widget-input {\n width: 100%;\n min-height: 40px;\n padding: 9px 36px 9px 10px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n line-height: 1.4;\n transition: border-color 0.15s ease, box-shadow 0.15s ease;\n\n appearance: none !important;\n background-color: #ffffff !important;\n color: #0f172a !important;\n cursor: default;\n }\n\n .mljar-select-widget-input:focus {\n outline: none;\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n cursor: text;\n }\n\n .mljar-select-caret {\n position: absolute;\n right: 12px;\n top: 50%;\n width: 8px;\n height: 8px;\n border-right: 1.5px solid #0f172a;\n border-bottom: 1.5px solid #0f172a;\n transform: translateY(-65%) rotate(45deg);\n pointer-events: none;\n opacity: 0.5;\n transition: transform 0.18s ease, opacity 0.18s ease;\n }\n\n .mljar-select-container.is-open .mljar-select-caret {\n opacity: 1;\n transform: translateY(-35%) rotate(225deg);\n }\n\n .mljar-select-dropdown {\n display: none;\n margin-top: 6px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);\n overflow: hidden;\n }\n\n .mljar-select-list {\n max-height: 260px;\n overflow-y: auto;\n }\n\n .mljar-select-option {\n display: block;\n width: 100%;\n padding: 9px 10px;\n border: 0;\n background: transparent;\n color: #0f172a;\n text-align: left;\n cursor: pointer;\n font: inherit;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-select-option:hover {\n background: #f3f3f4;\n }\n\n .mljar-select-option:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-select-option.is-selected {\n background: #e6f2ff;\n color: #007bff;\n font-weight: 600;\n }\n\n .mljar-select-option.is-selected:hover,\n .mljar-select-option.is-selected:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-select-empty {\n display: none;\n padding: 10px;\n color: #616673;\n font-size: 0.95em;\n }\n\n .mljar-select-widget-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-select-control.is-disabled .mljar-select-caret {\n opacity: 0.45;\n }\n ", "_dom_classes": [], "_esm": "\n function render({ model, el }) {\n const normalize = value => String(value ?? \"\").toLowerCase().trim();\n const getChoices = () =>\n Array.isArray(model.get(\"choices\")) ? [...model.get(\"choices\")] : [];\n const isDisabled = () => !!model.get(\"disabled\");\n const isHidden = () => !!model.get(\"hidden\");\n\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-select-container\");\n\n if (model.get(\"label\")) {\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-select-label\");\n topLabel.innerHTML = model.get(\"label\");\n container.appendChild(topLabel);\n }\n\n const control = document.createElement(\"div\");\n control.classList.add(\"mljar-select-control\");\n\n const input = document.createElement(\"input\");\n input.type = \"text\";\n input.classList.add(\"mljar-select-widget-input\");\n input.autocomplete = \"off\";\n input.spellcheck = false;\n\n const caret = document.createElement(\"div\");\n caret.classList.add(\"mljar-select-caret\");\n\n control.appendChild(input);\n control.appendChild(caret);\n\n const dropdown = document.createElement(\"div\");\n dropdown.classList.add(\"mljar-select-dropdown\");\n\n const list = document.createElement(\"div\");\n list.classList.add(\"mljar-select-list\");\n\n const emptyState = document.createElement(\"div\");\n emptyState.classList.add(\"mljar-select-empty\");\n emptyState.textContent = \"No matches\";\n\n dropdown.appendChild(list);\n dropdown.appendChild(emptyState);\n\n container.appendChild(control);\n container.appendChild(dropdown);\n el.appendChild(container);\n\n let isOpen = false;\n let filteredChoices = [];\n let lastCommittedValue = \"\";\n let isEditing = false;\n\n const setOpen = next => {\n if (isDisabled()) {\n isOpen = false;\n } else {\n isOpen = !!next;\n }\n container.classList.toggle(\"is-open\", isOpen);\n dropdown.style.display = isOpen ? \"block\" : \"none\";\n };\n\n const updateDisabledState = () => {\n const disabled = isDisabled();\n input.disabled = disabled;\n control.classList.toggle(\"is-disabled\", disabled);\n };\n\n const updateHiddenState = () => {\n container.style.display = isHidden() ? \"none\" : \"\";\n };\n\n const syncInputWithValue = () => {\n const value = model.get(\"value\") || \"\";\n lastCommittedValue = value;\n if (!isEditing) {\n input.value = value;\n }\n };\n\n const filterChoices = query => {\n const normalizedQuery = normalize(query);\n const allChoices = getChoices();\n if (!normalizedQuery) {\n return allChoices;\n }\n return allChoices.filter(choice =>\n normalize(choice).includes(normalizedQuery)\n );\n };\n\n const renderList = () => {\n list.innerHTML = \"\";\n filteredChoices.forEach(choice => {\n const option = document.createElement(\"button\");\n option.type = \"button\";\n option.classList.add(\"mljar-select-option\");\n if (choice === model.get(\"value\")) {\n option.classList.add(\"is-selected\");\n }\n option.textContent = choice;\n option.addEventListener(\"mousedown\", event => {\n event.preventDefault();\n event.stopPropagation();\n model.set(\"value\", choice);\n model.save_changes();\n isEditing = false;\n syncInputWithValue();\n renderList();\n setOpen(false);\n });\n list.appendChild(option);\n });\n\n const hasMatches = filteredChoices.length > 0;\n list.style.display = hasMatches ? \"block\" : \"none\";\n emptyState.style.display = hasMatches ? \"none\" : \"block\";\n };\n\n const refreshList = () => {\n filteredChoices = filterChoices(input.value);\n renderList();\n };\n\n const openWithCurrentQuery = () => {\n isEditing = true;\n input.value = \"\";\n refreshList();\n setOpen(true);\n };\n\n control.addEventListener(\"click\", event => {\n event.stopPropagation();\n if (isDisabled()) {\n return;\n }\n openWithCurrentQuery();\n input.focus();\n });\n\n input.addEventListener(\"input\", () => {\n if (isDisabled()) {\n return;\n }\n refreshList();\n setOpen(true);\n });\n\n input.addEventListener(\"focus\", () => {\n if (isDisabled()) {\n return;\n }\n openWithCurrentQuery();\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n input.value = lastCommittedValue;\n });\n\n const handleDocumentClick = event => {\n if (!container.contains(event.target)) {\n isEditing = false;\n setOpen(false);\n input.value = lastCommittedValue;\n }\n };\n\n document.addEventListener(\"click\", handleDocumentClick);\n\n model.on(\"change:value\", () => {\n syncInputWithValue();\n refreshList();\n });\n\n model.on(\"change:choices\", () => {\n const choices = getChoices();\n if (!choices.includes(model.get(\"value\")) && choices.length > 0) {\n model.set(\"value\", choices[0]);\n model.save_changes();\n return;\n }\n syncInputWithValue();\n refreshList();\n });\n\n model.on(\"change:disabled\", () => {\n updateDisabledState();\n if (isDisabled()) {\n isEditing = false;\n setOpen(false);\n }\n });\n\n model.on(\"change:hidden\", () => {\n updateHiddenState();\n });\n\n updateDisabledState();\n updateHiddenState();\n syncInputWithValue();\n refreshList();\n setOpen(false);\n\n return () => {\n document.removeEventListener(\"click\", handleDocumentClick);\n };\n }\n export default { render };\n ", "_model_module": "anywidget", "_model_module_version": "~0.11.*", "_model_name": "AnyModel", "_view_count": null, "_view_module": "anywidget", "_view_module_version": "~0.11.*", "_view_name": "AnyView", "cell_id": "", "choices": [ "13.5% (deck)", "8.0% (CTM treasury)" ], "disabled": false, "hidden": false, "label": "NPV discount rate", "layout": "IPY_MODEL_0d71f3541aea48d29335b75aabae1a05", "layout_path": null, "position": "sidebar", "render_slot_id": null, "source_cell_id": null, "tabbable": null, "tooltip": null, "url_key": "", "value": "13.5% (deck)" } }, "74d727dbe17e46cb9c8da2283a3acb50": { "model_module": "@jupyter-widgets/base", "model_module_version": "2.0.0", "model_name": "LayoutModel", "state": { "_model_module": "@jupyter-widgets/base", "_model_module_version": "2.0.0", "_model_name": "LayoutModel", "_view_count": null, "_view_module": "@jupyter-widgets/base", "_view_module_version": "2.0.0", "_view_name": "LayoutView", "align_content": null, "align_items": null, "align_self": null, "border_bottom": null, "border_left": null, "border_right": null, "border_top": null, "bottom": null, "display": null, "flex": null, "flex_flow": null, "grid_area": null, "grid_auto_columns": null, "grid_auto_flow": null, "grid_auto_rows": null, "grid_column": null, "grid_gap": null, "grid_row": null, "grid_template_areas": null, "grid_template_columns": null, "grid_template_rows": null, "height": null, "justify_content": null, "justify_items": null, "left": null, "margin": null, "max_height": null, "max_width": null, "min_height": null, "min_width": null, "object_fit": null, "object_position": null, "order": null, "overflow": null, "padding": null, "right": null, "top": null, "visibility": null, "width": null } }, "800dd2c22a2c4172ae2a846f620f97d9": { "model_module": "@jupyter-widgets/base", "model_module_version": "2.0.0", "model_name": "LayoutModel", "state": { "_model_module": "@jupyter-widgets/base", "_model_module_version": "2.0.0", "_model_name": "LayoutModel", "_view_count": null, "_view_module": "@jupyter-widgets/base", "_view_module_version": "2.0.0", "_view_name": "LayoutView", "align_content": null, "align_items": null, "align_self": null, "border_bottom": null, "border_left": null, "border_right": null, "border_top": null, "bottom": null, "display": null, "flex": null, "flex_flow": null, "grid_area": null, "grid_auto_columns": null, "grid_auto_flow": null, "grid_auto_rows": null, "grid_column": null, "grid_gap": null, "grid_row": null, "grid_template_areas": null, "grid_template_columns": null, "grid_template_rows": null, "height": null, "justify_content": null, "justify_items": null, "left": null, "margin": null, "max_height": null, "max_width": null, "min_height": null, "min_width": null, "object_fit": null, "object_position": null, "order": null, "overflow": null, "padding": null, "right": null, "top": null, "visibility": null, "width": null } }, "8e89c81717ff408d9e5cb1a949ab337c": { "model_module": "anywidget", "model_module_version": "~0.11.*", "model_name": "AnyModel", "state": { "_anywidget_id": "mercury.date.DateInputWidget", "_css": "\n .mljar-date-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-date-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-date-input {\n width: 100%;\n min-height: 40px;\n padding: 9px 10px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n color: #0f172a;\n box-sizing: border-box;\n line-height: 1.4;\n }\n\n .mljar-date-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-date-input:focus {\n outline: none;\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n }\n\n @media (max-width: 768px) {\n .mljar-date-input {\n min-height: 44px;\n padding: 10px 12px;\n }\n }\n ", "_dom_classes": [], "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-date-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-date-label\");\n\n const input = document.createElement(\"input\");\n input.type = \"date\";\n input.classList.add(\"mljar-date-input\");\n\n container.appendChild(topLabel);\n container.appendChild(input);\n el.appendChild(container);\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Date\";\n input.value = model.get(\"value\") || \"\";\n\n const min = model.get(\"min\") || \"\";\n const max = model.get(\"max\") || \"\";\n if (min) input.min = min; else input.removeAttribute(\"min\");\n if (max) input.max = max; else input.removeAttribute(\"max\");\n\n input.disabled = !!model.get(\"disabled\");\n container.style.display = model.get(\"hidden\") ? \"none\" : \"flex\";\n }\n\n input.addEventListener(\"change\", () => {\n if (model.get(\"disabled\")) return;\n model.set(\"value\", input.value);\n model.save_changes();\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n }\n export default { render };\n ", "_model_module": "anywidget", "_model_module_version": "~0.11.*", "_model_name": "AnyModel", "_view_count": null, "_view_module": "anywidget", "_view_module_version": "~0.11.*", "_view_name": "AnyView", "cell_id": "", "disabled": false, "hidden": false, "label": "EMEA — contract termination", "layout": "IPY_MODEL_098f9852a68245118ba3cb197da7c299", "layout_path": null, "max": "", "min": "", "position": "inline", "render_slot_id": null, "source_cell_id": null, "tabbable": null, "tooltip": null, "url_key": "", "value": "2027-12-31" } }, "9a1300f4e5374fa6bb1c9717866f8ce1": { "model_module": "anywidget", "model_module_version": "~0.11.*", "model_name": "AnyModel", "state": { "_anywidget_id": "mercury.number.NumberInputWidget", "_css": "\n .mljar-number-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-number-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-number-field-row {\n display: flex;\n align-items: stretch;\n width: 100%;\n min-height: 40px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n overflow: hidden;\n }\n\n .mljar-number-input {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 100%;\n padding: 7px 10px;\n border: 0;\n border-radius: 0;\n background: #ffffff;\n box-sizing: border-box;\n background-color: #ffffff !important;\n color: #0f172a !important;\n font: inherit;\n line-height: 1.2;\n -moz-appearance: textfield;\n }\n\n .mljar-number-input::-webkit-outer-spin-button,\n .mljar-number-input::-webkit-inner-spin-button {\n -webkit-appearance: none;\n margin: 0;\n }\n\n .mljar-number-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-number-input:focus {\n outline: none;\n }\n\n .mljar-number-field-row:focus-within {\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n }\n\n .mljar-number-field-row:focus-within .mljar-number-controls {\n border-left-color: #007bff;\n }\n\n .mljar-number-controls {\n display: flex;\n align-items: stretch;\n flex: 0 0 auto;\n border-left: 1px solid #cfd1d5;\n background: #f1f1f2;\n }\n\n .mljar-number-step-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 38px;\n min-width: 38px;\n min-height: 100%;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: #0f172a;\n font: inherit;\n font-size: 18px;\n font-weight: 700;\n line-height: 1;\n cursor: pointer;\n padding: 0;\n user-select: none;\n -webkit-user-select: none;\n touch-action: manipulation;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-number-step-up {\n border-left: 1px solid #cfd1d5;\n }\n\n .mljar-number-step-btn:hover {\n background: #f3f3f4;\n }\n\n .mljar-number-step-btn:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:focus-visible {\n outline: none;\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:disabled {\n background: #f5f5f5;\n color: #aaa;\n cursor: not-allowed;\n }\n\n @media (max-width: 768px) {\n .mljar-number-field-row {\n min-height: 44px;\n }\n\n .mljar-number-input {\n min-height: 44px;\n padding: 8px 12px;\n }\n\n .mljar-number-step-btn {\n font-size: 19px;\n width: 44px;\n min-width: 44px;\n }\n }\n ", "_dom_classes": [], "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-number-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-number-label\");\n\n const fieldRow = document.createElement(\"div\");\n fieldRow.classList.add(\"mljar-number-field-row\");\n\n const input = document.createElement(\"input\");\n input.type = \"number\";\n input.classList.add(\"mljar-number-input\");\n\n const decrementBtn = document.createElement(\"button\");\n decrementBtn.type = \"button\";\n decrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-down\");\n decrementBtn.textContent = \"-\";\n decrementBtn.setAttribute(\"aria-label\", \"Decrease value\");\n\n const incrementBtn = document.createElement(\"button\");\n incrementBtn.type = \"button\";\n incrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-up\");\n incrementBtn.textContent = \"+\";\n incrementBtn.setAttribute(\"aria-label\", \"Increase value\");\n\n const controls = document.createElement(\"div\");\n controls.classList.add(\"mljar-number-controls\");\n\n controls.appendChild(decrementBtn);\n controls.appendChild(incrementBtn);\n fieldRow.appendChild(input);\n fieldRow.appendChild(controls);\n\n container.appendChild(topLabel);\n container.appendChild(fieldRow);\n el.appendChild(container);\n\n function clamp(val, min, max) {\n if (Number.isFinite(min) && val < min) return min;\n if (Number.isFinite(max) && val > max) return max;\n return val;\n }\n\n function normalizeStep(step) {\n return Number.isFinite(step) && step > 0 ? step : 1;\n }\n\n function snapToStep(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = Math.round((value - base) / safeStep);\n const snapped = base + steps * safeStep;\n const precision = Math.max(\n 0,\n (String(safeStep).split(\".\")[1] || \"\").length\n );\n\n return Number(snapped.toFixed(precision + 2));\n }\n\n function isOnStepGrid(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = (value - base) / safeStep;\n const nearest = Math.round(steps);\n const epsilon = Math.max(1e-9, safeStep * 1e-9);\n\n return Math.abs(steps - nearest) <= epsilon;\n }\n\n function getCurrentBounds() {\n return {\n min: Number(model.get(\"min\")),\n max: Number(model.get(\"max\")),\n };\n }\n\n function isTransientDraft(raw) {\n return raw === \"\" || raw === \"-\" || raw === \".\" || raw === \"-.\";\n }\n\n let isEditing = false;\n const INPUT_COMMIT_DEBOUNCE_MS = 400;\n\n function clearPendingDraftCommit() {\n if (debounceTimer) clearTimeout(debounceTimer);\n pendingDraftValue = null;\n }\n\n function parseDraftValue(rawValue) {\n const raw = String(rawValue).trim();\n if (isTransientDraft(raw)) {\n return { kind: \"transient\" };\n }\n\n const value = Number(raw);\n if (!Number.isFinite(value)) {\n return { kind: \"invalid\" };\n }\n\n return { kind: \"number\", value };\n }\n\n function commitValue(nextValue, saveNow = true) {\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n const parsed = parseDraftValue(nextValue);\n if (parsed.kind !== \"number\") {\n syncFromModel();\n return;\n }\n\n let v = parsed.value;\n v = clamp(v, min, max);\n v = snapToStep(v, min, step);\n v = clamp(v, min, max);\n input.value = String(v);\n model.set(\"value\", v);\n\n if (saveNow) {\n model.save_changes();\n }\n }\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Enter number\";\n\n const min = Number(model.get(\"min\"));\n const max = Number(model.get(\"max\"));\n const step = Number(model.get(\"step\"));\n\n if (Number.isFinite(min)) input.min = String(min); else input.removeAttribute(\"min\");\n if (Number.isFinite(max)) input.max = String(max); else input.removeAttribute(\"max\");\n if (Number.isFinite(step)) input.step = String(step); else input.removeAttribute(\"step\");\n\n const v = Number(model.get(\"value\"));\n if (!isEditing) {\n input.value = Number.isFinite(v) ? String(v) : \"\";\n }\n\n const disabled = !!model.get(\"disabled\");\n input.disabled = disabled;\n incrementBtn.disabled = disabled;\n decrementBtn.disabled = disabled;\n\n const hidden = !!model.get(\"hidden\");\n container.style.display = hidden ? \"none\" : \"flex\";\n }\n\n let debounceTimer = null;\n let pendingDraftValue = null;\n input.addEventListener(\"focus\", () => {\n isEditing = true;\n });\n\n input.addEventListener(\"input\", () => {\n if (model.get(\"disabled\")) return;\n\n const parsed = parseDraftValue(input.value);\n if (parsed.kind !== \"number\") {\n clearPendingDraftCommit();\n return;\n }\n\n const v = parsed.value;\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n if (Number.isFinite(min) && v < min) {\n clearPendingDraftCommit();\n return;\n }\n if (Number.isFinite(max) && v > max) {\n clearPendingDraftCommit();\n return;\n }\n if (!isOnStepGrid(v, min, step)) {\n clearPendingDraftCommit();\n return;\n }\n\n pendingDraftValue = v;\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n if (pendingDraftValue === null) return;\n model.set(\"value\", pendingDraftValue);\n model.save_changes();\n pendingDraftValue = null;\n }, INPUT_COMMIT_DEBOUNCE_MS);\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n clearPendingDraftCommit();\n commitValue(input.value, true);\n });\n\n input.addEventListener(\"keydown\", event => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n input.blur();\n }\n });\n\n incrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base + step, min, step));\n });\n\n decrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base - step, min, step));\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:step\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }*/\n }\n export default { render };\n ", "_model_module": "anywidget", "_model_module_version": "~0.11.*", "_model_name": "AnyModel", "_view_count": null, "_view_module": "anywidget", "_view_module_version": "~0.11.*", "_view_name": "AnyView", "cell_id": "", "disabled": false, "hidden": false, "label": "EMEA — current platform cost ($/yr)", "layout": "IPY_MODEL_423a009e5b26436c9ccdb69e031722a4", "layout_path": null, "max": 8000000.0, "min": 0.0, "position": "inline", "render_slot_id": null, "source_cell_id": null, "step": 10000.0, "tabbable": null, "tooltip": null, "url_key": "", "value": 1204124.0 } }, "b2ec5338e0ba4055b77a57edd08b464c": { "model_module": "anywidget", "model_module_version": "~0.11.*", "model_name": "AnyModel", "state": { "_anywidget_id": "mercury.date.DateInputWidget", "_css": "\n .mljar-date-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-date-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-date-input {\n width: 100%;\n min-height: 40px;\n padding: 9px 10px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n color: #0f172a;\n box-sizing: border-box;\n line-height: 1.4;\n }\n\n .mljar-date-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-date-input:focus {\n outline: none;\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n }\n\n @media (max-width: 768px) {\n .mljar-date-input {\n min-height: 44px;\n padding: 10px 12px;\n }\n }\n ", "_dom_classes": [], "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-date-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-date-label\");\n\n const input = document.createElement(\"input\");\n input.type = \"date\";\n input.classList.add(\"mljar-date-input\");\n\n container.appendChild(topLabel);\n container.appendChild(input);\n el.appendChild(container);\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Date\";\n input.value = model.get(\"value\") || \"\";\n\n const min = model.get(\"min\") || \"\";\n const max = model.get(\"max\") || \"\";\n if (min) input.min = min; else input.removeAttribute(\"min\");\n if (max) input.max = max; else input.removeAttribute(\"max\");\n\n input.disabled = !!model.get(\"disabled\");\n container.style.display = model.get(\"hidden\") ? \"none\" : \"flex\";\n }\n\n input.addEventListener(\"change\", () => {\n if (model.get(\"disabled\")) return;\n model.set(\"value\", input.value);\n model.save_changes();\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n }\n export default { render };\n ", "_model_module": "anywidget", "_model_module_version": "~0.11.*", "_model_name": "AnyModel", "_view_count": null, "_view_module": "anywidget", "_view_module_version": "~0.11.*", "_view_name": "AnyView", "cell_id": "", "disabled": false, "hidden": false, "label": "NA — contract termination", "layout": "IPY_MODEL_e7597a4eb1c14475a440ac83ee50ce1c", "layout_path": null, "max": "", "min": "", "position": "inline", "render_slot_id": null, "source_cell_id": null, "tabbable": null, "tooltip": null, "url_key": "", "value": "2027-12-31" } }, "b50136dc74fb4036b657d83d7b10c5e6": { "model_module": "@jupyter-widgets/base", "model_module_version": "2.0.0", "model_name": "LayoutModel", "state": { "_model_module": "@jupyter-widgets/base", "_model_module_version": "2.0.0", "_model_name": "LayoutModel", "_view_count": null, "_view_module": "@jupyter-widgets/base", "_view_module_version": "2.0.0", "_view_name": "LayoutView", "align_content": null, "align_items": null, "align_self": null, "border_bottom": null, "border_left": null, "border_right": null, "border_top": null, "bottom": null, "display": null, "flex": null, "flex_flow": null, "grid_area": null, "grid_auto_columns": null, "grid_auto_flow": null, "grid_auto_rows": null, "grid_column": null, "grid_gap": null, "grid_row": null, "grid_template_areas": null, "grid_template_columns": null, "grid_template_rows": null, "height": null, "justify_content": null, "justify_items": null, "left": null, "margin": null, "max_height": null, "max_width": null, "min_height": null, "min_width": null, "object_fit": null, "object_position": null, "order": null, "overflow": null, "padding": null, "right": null, "top": null, "visibility": null, "width": null } }, "cc5b28489de346d3b59e3ace05fb2ebb": { "model_module": "anywidget", "model_module_version": "~0.11.*", "model_name": "AnyModel", "state": { "_anywidget_id": "mercury.number.NumberInputWidget", "_css": "\n .mljar-number-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-number-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-number-field-row {\n display: flex;\n align-items: stretch;\n width: 100%;\n min-height: 40px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n overflow: hidden;\n }\n\n .mljar-number-input {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 100%;\n padding: 7px 10px;\n border: 0;\n border-radius: 0;\n background: #ffffff;\n box-sizing: border-box;\n background-color: #ffffff !important;\n color: #0f172a !important;\n font: inherit;\n line-height: 1.2;\n -moz-appearance: textfield;\n }\n\n .mljar-number-input::-webkit-outer-spin-button,\n .mljar-number-input::-webkit-inner-spin-button {\n -webkit-appearance: none;\n margin: 0;\n }\n\n .mljar-number-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-number-input:focus {\n outline: none;\n }\n\n .mljar-number-field-row:focus-within {\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n }\n\n .mljar-number-field-row:focus-within .mljar-number-controls {\n border-left-color: #007bff;\n }\n\n .mljar-number-controls {\n display: flex;\n align-items: stretch;\n flex: 0 0 auto;\n border-left: 1px solid #cfd1d5;\n background: #f1f1f2;\n }\n\n .mljar-number-step-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 38px;\n min-width: 38px;\n min-height: 100%;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: #0f172a;\n font: inherit;\n font-size: 18px;\n font-weight: 700;\n line-height: 1;\n cursor: pointer;\n padding: 0;\n user-select: none;\n -webkit-user-select: none;\n touch-action: manipulation;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-number-step-up {\n border-left: 1px solid #cfd1d5;\n }\n\n .mljar-number-step-btn:hover {\n background: #f3f3f4;\n }\n\n .mljar-number-step-btn:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:focus-visible {\n outline: none;\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:disabled {\n background: #f5f5f5;\n color: #aaa;\n cursor: not-allowed;\n }\n\n @media (max-width: 768px) {\n .mljar-number-field-row {\n min-height: 44px;\n }\n\n .mljar-number-input {\n min-height: 44px;\n padding: 8px 12px;\n }\n\n .mljar-number-step-btn {\n font-size: 19px;\n width: 44px;\n min-width: 44px;\n }\n }\n ", "_dom_classes": [], "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-number-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-number-label\");\n\n const fieldRow = document.createElement(\"div\");\n fieldRow.classList.add(\"mljar-number-field-row\");\n\n const input = document.createElement(\"input\");\n input.type = \"number\";\n input.classList.add(\"mljar-number-input\");\n\n const decrementBtn = document.createElement(\"button\");\n decrementBtn.type = \"button\";\n decrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-down\");\n decrementBtn.textContent = \"-\";\n decrementBtn.setAttribute(\"aria-label\", \"Decrease value\");\n\n const incrementBtn = document.createElement(\"button\");\n incrementBtn.type = \"button\";\n incrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-up\");\n incrementBtn.textContent = \"+\";\n incrementBtn.setAttribute(\"aria-label\", \"Increase value\");\n\n const controls = document.createElement(\"div\");\n controls.classList.add(\"mljar-number-controls\");\n\n controls.appendChild(decrementBtn);\n controls.appendChild(incrementBtn);\n fieldRow.appendChild(input);\n fieldRow.appendChild(controls);\n\n container.appendChild(topLabel);\n container.appendChild(fieldRow);\n el.appendChild(container);\n\n function clamp(val, min, max) {\n if (Number.isFinite(min) && val < min) return min;\n if (Number.isFinite(max) && val > max) return max;\n return val;\n }\n\n function normalizeStep(step) {\n return Number.isFinite(step) && step > 0 ? step : 1;\n }\n\n function snapToStep(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = Math.round((value - base) / safeStep);\n const snapped = base + steps * safeStep;\n const precision = Math.max(\n 0,\n (String(safeStep).split(\".\")[1] || \"\").length\n );\n\n return Number(snapped.toFixed(precision + 2));\n }\n\n function isOnStepGrid(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = (value - base) / safeStep;\n const nearest = Math.round(steps);\n const epsilon = Math.max(1e-9, safeStep * 1e-9);\n\n return Math.abs(steps - nearest) <= epsilon;\n }\n\n function getCurrentBounds() {\n return {\n min: Number(model.get(\"min\")),\n max: Number(model.get(\"max\")),\n };\n }\n\n function isTransientDraft(raw) {\n return raw === \"\" || raw === \"-\" || raw === \".\" || raw === \"-.\";\n }\n\n let isEditing = false;\n const INPUT_COMMIT_DEBOUNCE_MS = 400;\n\n function clearPendingDraftCommit() {\n if (debounceTimer) clearTimeout(debounceTimer);\n pendingDraftValue = null;\n }\n\n function parseDraftValue(rawValue) {\n const raw = String(rawValue).trim();\n if (isTransientDraft(raw)) {\n return { kind: \"transient\" };\n }\n\n const value = Number(raw);\n if (!Number.isFinite(value)) {\n return { kind: \"invalid\" };\n }\n\n return { kind: \"number\", value };\n }\n\n function commitValue(nextValue, saveNow = true) {\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n const parsed = parseDraftValue(nextValue);\n if (parsed.kind !== \"number\") {\n syncFromModel();\n return;\n }\n\n let v = parsed.value;\n v = clamp(v, min, max);\n v = snapToStep(v, min, step);\n v = clamp(v, min, max);\n input.value = String(v);\n model.set(\"value\", v);\n\n if (saveNow) {\n model.save_changes();\n }\n }\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Enter number\";\n\n const min = Number(model.get(\"min\"));\n const max = Number(model.get(\"max\"));\n const step = Number(model.get(\"step\"));\n\n if (Number.isFinite(min)) input.min = String(min); else input.removeAttribute(\"min\");\n if (Number.isFinite(max)) input.max = String(max); else input.removeAttribute(\"max\");\n if (Number.isFinite(step)) input.step = String(step); else input.removeAttribute(\"step\");\n\n const v = Number(model.get(\"value\"));\n if (!isEditing) {\n input.value = Number.isFinite(v) ? String(v) : \"\";\n }\n\n const disabled = !!model.get(\"disabled\");\n input.disabled = disabled;\n incrementBtn.disabled = disabled;\n decrementBtn.disabled = disabled;\n\n const hidden = !!model.get(\"hidden\");\n container.style.display = hidden ? \"none\" : \"flex\";\n }\n\n let debounceTimer = null;\n let pendingDraftValue = null;\n input.addEventListener(\"focus\", () => {\n isEditing = true;\n });\n\n input.addEventListener(\"input\", () => {\n if (model.get(\"disabled\")) return;\n\n const parsed = parseDraftValue(input.value);\n if (parsed.kind !== \"number\") {\n clearPendingDraftCommit();\n return;\n }\n\n const v = parsed.value;\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n if (Number.isFinite(min) && v < min) {\n clearPendingDraftCommit();\n return;\n }\n if (Number.isFinite(max) && v > max) {\n clearPendingDraftCommit();\n return;\n }\n if (!isOnStepGrid(v, min, step)) {\n clearPendingDraftCommit();\n return;\n }\n\n pendingDraftValue = v;\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n if (pendingDraftValue === null) return;\n model.set(\"value\", pendingDraftValue);\n model.save_changes();\n pendingDraftValue = null;\n }, INPUT_COMMIT_DEBOUNCE_MS);\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n clearPendingDraftCommit();\n commitValue(input.value, true);\n });\n\n input.addEventListener(\"keydown\", event => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n input.blur();\n }\n });\n\n incrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base + step, min, step));\n });\n\n decrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base - step, min, step));\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:step\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }*/\n }\n export default { render };\n ", "_model_module": "anywidget", "_model_module_version": "~0.11.*", "_model_name": "AnyModel", "_view_count": null, "_view_module": "anywidget", "_view_module_version": "~0.11.*", "_view_name": "AnyView", "cell_id": "", "disabled": false, "hidden": false, "label": "Genesys licence run-rate ($/yr)", "layout": "IPY_MODEL_800dd2c22a2c4172ae2a846f620f97d9", "layout_path": null, "max": 10000000.0, "min": 0.0, "position": "sidebar", "render_slot_id": null, "source_cell_id": null, "step": 100000.0, "tabbable": null, "tooltip": null, "url_key": "", "value": 3200000.0 } }, "cddbd4fd3deb4ed1a29dad5cf082939b": { "model_module": "anywidget", "model_module_version": "~0.11.*", "model_name": "AnyModel", "state": { "_anywidget_id": "mercury.checkbox.CheckboxWidget", "_css": "\n .mljar-checkbox-container {\n display: inline-flex;\n align-items: center;\n gap: 10px;\n cursor: pointer;\n user-select: none;\n -webkit-tap-highlight-color: transparent;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n color: #0f172a;\n padding-left: 5px;\n }\n .mljar-checkbox-container.is-disabled {\n opacity: 0.6;\n cursor: not-allowed;\n }\n\n .mljar-checkbox-input {\n position: absolute;\n opacity: 0;\n width: 0;\n height: 0;\n }\n\n .mljar-checkbox-input:focus-visible + .mljar-checkbox-control {\n box-shadow: inset 0 0 0 2px #007bff;\n }\n\n .mljar-checkbox-label {\n font-size: 14px;\n line-height: 1.2;\n padding-top: 2px;\n }\n\n /* --- Toggle style (15% smaller) --- */\n .mljar-checkbox-container.is-toggle .mljar-checkbox-control {\n position: relative;\n width: 34px;\n height: 19px;\n border-radius: 999px;\n background: #f1f1f2;\n border: 1px solid #cfd1d5;\n transition: background 150ms ease, border-color 150ms ease;\n }\n .mljar-checkbox-container.is-toggle.is-checked .mljar-checkbox-control {\n background: #007bff;\n border-color: #007bff;\n }\n .mljar-checkbox-container.is-toggle .mljar-checkbox-control::after {\n content: \"\";\n position: absolute;\n top: 2px;\n left: 2px;\n width: 15px;\n height: 15px;\n border-radius: 50%;\n background: #ffffff;\n box-shadow: 0 1px 2px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.04);\n transition: transform 150ms ease;\n }\n .mljar-checkbox-container.is-toggle.is-checked .mljar-checkbox-control::after {\n transform: translateX(15px);\n }\n\n /* --- Classic box style --- */\n .mljar-checkbox-container.is-box .mljar-checkbox-control {\n width: 16px;\n height: 16px;\n border-radius: 4px;\n border: 1px solid #cfd1d5;\n background: #ffffff;\n display: inline-block;\n position: relative;\n }\n .mljar-checkbox-container.is-box.is-checked .mljar-checkbox-control {\n border-color: #007bff;\n background: #007bff;\n }\n .mljar-checkbox-container.is-box.is-checked .mljar-checkbox-control::after {\n content: \"\";\n position: absolute;\n left: 4px;\n top: 0px;\n width: 5px;\n height: 10px;\n border: solid #fff;\n border-width: 0 2px 2px 0;\n transform: rotate(45deg);\n }\n\n .mljar-checkbox-container:not(.is-disabled):hover .mljar-checkbox-control {\n border-color: #007bff;\n background: #f3f3f4;\n }\n ", "_dom_classes": [], "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"label\");\n container.classList.add(\"mljar-checkbox-container\");\n\n const input = document.createElement(\"input\");\n input.type = \"checkbox\";\n input.classList.add(\"mljar-checkbox-input\");\n\n const control = document.createElement(\"span\");\n control.classList.add(\"mljar-checkbox-control\");\n\n const text = document.createElement(\"span\");\n text.classList.add(\"mljar-checkbox-label\");\n\n container.appendChild(input);\n container.appendChild(control);\n container.appendChild(text);\n el.appendChild(container);\n\n function syncFromModel() {\n // appearance\n const a = model.get(\"appearance\") || \"toggle\";\n container.classList.remove(\"is-toggle\",\"is-box\");\n container.classList.add(`is-${a}`);\n input.setAttribute(\"role\", a === \"toggle\" ? \"switch\" : \"checkbox\");\n\n // value\n const v = !!model.get(\"value\");\n if (input.checked !== v) {\n input.checked = v;\n input.setAttribute(\"aria-checked\", String(v));\n }\n container.classList.toggle(\"is-checked\", v);\n\n // disabled\n const d = !!model.get(\"disabled\");\n input.disabled = d;\n container.classList.toggle(\"is-disabled\", d);\n\n // label\n text.textContent = model.get(\"label\") || \"\";\n\n // hidden (exists but not visible)\n container.style.display = model.get(\"hidden\") ? \"none\" : \"inline-flex\";\n }\n\n input.addEventListener(\"change\", () => {\n if (model.get(\"disabled\")) return;\n\n const v = input.checked;\n if (model.get(\"value\") !== v) {\n model.set(\"value\", v);\n model.set(\"last_changed_at\", new Date().toISOString());\n model.set(\"n_toggles\", (model.get(\"n_toggles\") || 0) + 1);\n model.save_changes();\n // model.send({ type: \"changed\", value: v });\n }\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:appearance\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n \n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }\n */\n }\n export default { render };\n ", "_model_module": "anywidget", "_model_module_version": "~0.11.*", "_model_name": "AnyModel", "_view_count": null, "_view_module": "anywidget", "_view_module_version": "~0.11.*", "_view_name": "AnyView", "appearance": "toggle", "cell_id": "", "disabled": false, "hidden": false, "label": "WFM — ANZ (implement)", "last_changed_at": "", "layout": "IPY_MODEL_de9d84197e314233938eb4ad7b19ad0b", "layout_path": null, "n_toggles": 0, "position": "sidebar", "render_slot_id": null, "source_cell_id": null, "tabbable": null, "tooltip": null, "url_key": "", "value": true } }, "cdf8fe1626a54e7eb048e9e8d27cb2a4": { "model_module": "anywidget", "model_module_version": "~0.11.*", "model_name": "AnyModel", "state": { "_anywidget_id": "mercury.number.NumberInputWidget", "_css": "\n .mljar-number-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-number-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-number-field-row {\n display: flex;\n align-items: stretch;\n width: 100%;\n min-height: 40px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n overflow: hidden;\n }\n\n .mljar-number-input {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 100%;\n padding: 7px 10px;\n border: 0;\n border-radius: 0;\n background: #ffffff;\n box-sizing: border-box;\n background-color: #ffffff !important;\n color: #0f172a !important;\n font: inherit;\n line-height: 1.2;\n -moz-appearance: textfield;\n }\n\n .mljar-number-input::-webkit-outer-spin-button,\n .mljar-number-input::-webkit-inner-spin-button {\n -webkit-appearance: none;\n margin: 0;\n }\n\n .mljar-number-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-number-input:focus {\n outline: none;\n }\n\n .mljar-number-field-row:focus-within {\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n }\n\n .mljar-number-field-row:focus-within .mljar-number-controls {\n border-left-color: #007bff;\n }\n\n .mljar-number-controls {\n display: flex;\n align-items: stretch;\n flex: 0 0 auto;\n border-left: 1px solid #cfd1d5;\n background: #f1f1f2;\n }\n\n .mljar-number-step-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 38px;\n min-width: 38px;\n min-height: 100%;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: #0f172a;\n font: inherit;\n font-size: 18px;\n font-weight: 700;\n line-height: 1;\n cursor: pointer;\n padding: 0;\n user-select: none;\n -webkit-user-select: none;\n touch-action: manipulation;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-number-step-up {\n border-left: 1px solid #cfd1d5;\n }\n\n .mljar-number-step-btn:hover {\n background: #f3f3f4;\n }\n\n .mljar-number-step-btn:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:focus-visible {\n outline: none;\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:disabled {\n background: #f5f5f5;\n color: #aaa;\n cursor: not-allowed;\n }\n\n @media (max-width: 768px) {\n .mljar-number-field-row {\n min-height: 44px;\n }\n\n .mljar-number-input {\n min-height: 44px;\n padding: 8px 12px;\n }\n\n .mljar-number-step-btn {\n font-size: 19px;\n width: 44px;\n min-width: 44px;\n }\n }\n ", "_dom_classes": [], "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-number-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-number-label\");\n\n const fieldRow = document.createElement(\"div\");\n fieldRow.classList.add(\"mljar-number-field-row\");\n\n const input = document.createElement(\"input\");\n input.type = \"number\";\n input.classList.add(\"mljar-number-input\");\n\n const decrementBtn = document.createElement(\"button\");\n decrementBtn.type = \"button\";\n decrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-down\");\n decrementBtn.textContent = \"-\";\n decrementBtn.setAttribute(\"aria-label\", \"Decrease value\");\n\n const incrementBtn = document.createElement(\"button\");\n incrementBtn.type = \"button\";\n incrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-up\");\n incrementBtn.textContent = \"+\";\n incrementBtn.setAttribute(\"aria-label\", \"Increase value\");\n\n const controls = document.createElement(\"div\");\n controls.classList.add(\"mljar-number-controls\");\n\n controls.appendChild(decrementBtn);\n controls.appendChild(incrementBtn);\n fieldRow.appendChild(input);\n fieldRow.appendChild(controls);\n\n container.appendChild(topLabel);\n container.appendChild(fieldRow);\n el.appendChild(container);\n\n function clamp(val, min, max) {\n if (Number.isFinite(min) && val < min) return min;\n if (Number.isFinite(max) && val > max) return max;\n return val;\n }\n\n function normalizeStep(step) {\n return Number.isFinite(step) && step > 0 ? step : 1;\n }\n\n function snapToStep(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = Math.round((value - base) / safeStep);\n const snapped = base + steps * safeStep;\n const precision = Math.max(\n 0,\n (String(safeStep).split(\".\")[1] || \"\").length\n );\n\n return Number(snapped.toFixed(precision + 2));\n }\n\n function isOnStepGrid(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = (value - base) / safeStep;\n const nearest = Math.round(steps);\n const epsilon = Math.max(1e-9, safeStep * 1e-9);\n\n return Math.abs(steps - nearest) <= epsilon;\n }\n\n function getCurrentBounds() {\n return {\n min: Number(model.get(\"min\")),\n max: Number(model.get(\"max\")),\n };\n }\n\n function isTransientDraft(raw) {\n return raw === \"\" || raw === \"-\" || raw === \".\" || raw === \"-.\";\n }\n\n let isEditing = false;\n const INPUT_COMMIT_DEBOUNCE_MS = 400;\n\n function clearPendingDraftCommit() {\n if (debounceTimer) clearTimeout(debounceTimer);\n pendingDraftValue = null;\n }\n\n function parseDraftValue(rawValue) {\n const raw = String(rawValue).trim();\n if (isTransientDraft(raw)) {\n return { kind: \"transient\" };\n }\n\n const value = Number(raw);\n if (!Number.isFinite(value)) {\n return { kind: \"invalid\" };\n }\n\n return { kind: \"number\", value };\n }\n\n function commitValue(nextValue, saveNow = true) {\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n const parsed = parseDraftValue(nextValue);\n if (parsed.kind !== \"number\") {\n syncFromModel();\n return;\n }\n\n let v = parsed.value;\n v = clamp(v, min, max);\n v = snapToStep(v, min, step);\n v = clamp(v, min, max);\n input.value = String(v);\n model.set(\"value\", v);\n\n if (saveNow) {\n model.save_changes();\n }\n }\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Enter number\";\n\n const min = Number(model.get(\"min\"));\n const max = Number(model.get(\"max\"));\n const step = Number(model.get(\"step\"));\n\n if (Number.isFinite(min)) input.min = String(min); else input.removeAttribute(\"min\");\n if (Number.isFinite(max)) input.max = String(max); else input.removeAttribute(\"max\");\n if (Number.isFinite(step)) input.step = String(step); else input.removeAttribute(\"step\");\n\n const v = Number(model.get(\"value\"));\n if (!isEditing) {\n input.value = Number.isFinite(v) ? String(v) : \"\";\n }\n\n const disabled = !!model.get(\"disabled\");\n input.disabled = disabled;\n incrementBtn.disabled = disabled;\n decrementBtn.disabled = disabled;\n\n const hidden = !!model.get(\"hidden\");\n container.style.display = hidden ? \"none\" : \"flex\";\n }\n\n let debounceTimer = null;\n let pendingDraftValue = null;\n input.addEventListener(\"focus\", () => {\n isEditing = true;\n });\n\n input.addEventListener(\"input\", () => {\n if (model.get(\"disabled\")) return;\n\n const parsed = parseDraftValue(input.value);\n if (parsed.kind !== \"number\") {\n clearPendingDraftCommit();\n return;\n }\n\n const v = parsed.value;\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n if (Number.isFinite(min) && v < min) {\n clearPendingDraftCommit();\n return;\n }\n if (Number.isFinite(max) && v > max) {\n clearPendingDraftCommit();\n return;\n }\n if (!isOnStepGrid(v, min, step)) {\n clearPendingDraftCommit();\n return;\n }\n\n pendingDraftValue = v;\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n if (pendingDraftValue === null) return;\n model.set(\"value\", pendingDraftValue);\n model.save_changes();\n pendingDraftValue = null;\n }, INPUT_COMMIT_DEBOUNCE_MS);\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n clearPendingDraftCommit();\n commitValue(input.value, true);\n });\n\n input.addEventListener(\"keydown\", event => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n input.blur();\n }\n });\n\n incrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base + step, min, step));\n });\n\n decrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base - step, min, step));\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:step\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }*/\n }\n export default { render };\n ", "_model_module": "anywidget", "_model_module_version": "~0.11.*", "_model_name": "AnyModel", "_view_count": null, "_view_module": "anywidget", "_view_module_version": "~0.11.*", "_view_name": "AnyView", "cell_id": "", "disabled": false, "hidden": false, "label": "ASIA — current platform cost ($/yr)", "layout": "IPY_MODEL_74d727dbe17e46cb9c8da2283a3acb50", "layout_path": null, "max": 8000000.0, "min": 0.0, "position": "inline", "render_slot_id": null, "source_cell_id": null, "step": 10000.0, "tabbable": null, "tooltip": null, "url_key": "", "value": 2069588.0 } }, "cf504293e15c4cd99f1a492d73d8e263": { "model_module": "@jupyter-widgets/controls", "model_module_version": "2.0.0", "model_name": "HTMLModel", "state": { "_dom_classes": [], "_model_module": "@jupyter-widgets/controls", "_model_module_version": "2.0.0", "_model_name": "HTMLModel", "_view_count": null, "_view_module": "@jupyter-widgets/controls", "_view_module_version": "2.0.0", "_view_name": "HTMLView", "cell_id": "", "description": "", "description_allow_html": false, "layout": "IPY_MODEL_6bc9b75143f244bbbb3a29cdbcb95e53", "layout_path": null, "placeholder": "​", "position": "sidebar", "render_slot_id": null, "source_cell_id": null, "style": "IPY_MODEL_f1dd0e56a7224aafa77c1c3c23e9fdb0", "tabbable": null, "tooltip": null, "value": "" } }, "d9afd02316174736b31a7964fe071605": { "model_module": "@jupyter-widgets/base", "model_module_version": "2.0.0", "model_name": "LayoutModel", "state": { "_model_module": "@jupyter-widgets/base", "_model_module_version": "2.0.0", "_model_name": "LayoutModel", "_view_count": null, "_view_module": "@jupyter-widgets/base", "_view_module_version": "2.0.0", "_view_name": "LayoutView", "align_content": null, "align_items": null, "align_self": null, "border_bottom": null, "border_left": null, "border_right": null, "border_top": null, "bottom": null, "display": null, "flex": null, "flex_flow": null, "grid_area": null, "grid_auto_columns": null, "grid_auto_flow": null, "grid_auto_rows": null, "grid_column": null, "grid_gap": null, "grid_row": null, "grid_template_areas": null, "grid_template_columns": null, "grid_template_rows": null, "height": null, "justify_content": null, "justify_items": null, "left": null, "margin": null, "max_height": null, "max_width": null, "min_height": null, "min_width": null, "object_fit": null, "object_position": null, "order": null, "overflow": null, "padding": null, "right": null, "top": null, "visibility": null, "width": null } }, "d9bccb3d954a4ad88eca96950703a866": { "model_module": "@jupyter-widgets/base", "model_module_version": "2.0.0", "model_name": "LayoutModel", "state": { "_model_module": "@jupyter-widgets/base", "_model_module_version": "2.0.0", "_model_name": "LayoutModel", "_view_count": null, "_view_module": "@jupyter-widgets/base", "_view_module_version": "2.0.0", "_view_name": "LayoutView", "align_content": null, "align_items": null, "align_self": null, "border_bottom": null, "border_left": null, "border_right": null, "border_top": null, "bottom": null, "display": null, "flex": null, "flex_flow": null, "grid_area": null, "grid_auto_columns": null, "grid_auto_flow": null, "grid_auto_rows": null, "grid_column": null, "grid_gap": null, "grid_row": null, "grid_template_areas": null, "grid_template_columns": null, "grid_template_rows": null, "height": null, "justify_content": null, "justify_items": null, "left": null, "margin": null, "max_height": null, "max_width": null, "min_height": null, "min_width": null, "object_fit": null, "object_position": null, "order": null, "overflow": null, "padding": null, "right": null, "top": null, "visibility": null, "width": null } }, "ddacc3befdc54c278abccd4a5b6b98b5": { "model_module": "anywidget", "model_module_version": "~0.11.*", "model_name": "AnyModel", "state": { "_anywidget_id": "mercury.number.NumberInputWidget", "_css": "\n .mljar-number-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-number-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-number-field-row {\n display: flex;\n align-items: stretch;\n width: 100%;\n min-height: 40px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n overflow: hidden;\n }\n\n .mljar-number-input {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 100%;\n padding: 7px 10px;\n border: 0;\n border-radius: 0;\n background: #ffffff;\n box-sizing: border-box;\n background-color: #ffffff !important;\n color: #0f172a !important;\n font: inherit;\n line-height: 1.2;\n -moz-appearance: textfield;\n }\n\n .mljar-number-input::-webkit-outer-spin-button,\n .mljar-number-input::-webkit-inner-spin-button {\n -webkit-appearance: none;\n margin: 0;\n }\n\n .mljar-number-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-number-input:focus {\n outline: none;\n }\n\n .mljar-number-field-row:focus-within {\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n }\n\n .mljar-number-field-row:focus-within .mljar-number-controls {\n border-left-color: #007bff;\n }\n\n .mljar-number-controls {\n display: flex;\n align-items: stretch;\n flex: 0 0 auto;\n border-left: 1px solid #cfd1d5;\n background: #f1f1f2;\n }\n\n .mljar-number-step-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 38px;\n min-width: 38px;\n min-height: 100%;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: #0f172a;\n font: inherit;\n font-size: 18px;\n font-weight: 700;\n line-height: 1;\n cursor: pointer;\n padding: 0;\n user-select: none;\n -webkit-user-select: none;\n touch-action: manipulation;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-number-step-up {\n border-left: 1px solid #cfd1d5;\n }\n\n .mljar-number-step-btn:hover {\n background: #f3f3f4;\n }\n\n .mljar-number-step-btn:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:focus-visible {\n outline: none;\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:disabled {\n background: #f5f5f5;\n color: #aaa;\n cursor: not-allowed;\n }\n\n @media (max-width: 768px) {\n .mljar-number-field-row {\n min-height: 44px;\n }\n\n .mljar-number-input {\n min-height: 44px;\n padding: 8px 12px;\n }\n\n .mljar-number-step-btn {\n font-size: 19px;\n width: 44px;\n min-width: 44px;\n }\n }\n ", "_dom_classes": [], "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-number-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-number-label\");\n\n const fieldRow = document.createElement(\"div\");\n fieldRow.classList.add(\"mljar-number-field-row\");\n\n const input = document.createElement(\"input\");\n input.type = \"number\";\n input.classList.add(\"mljar-number-input\");\n\n const decrementBtn = document.createElement(\"button\");\n decrementBtn.type = \"button\";\n decrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-down\");\n decrementBtn.textContent = \"-\";\n decrementBtn.setAttribute(\"aria-label\", \"Decrease value\");\n\n const incrementBtn = document.createElement(\"button\");\n incrementBtn.type = \"button\";\n incrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-up\");\n incrementBtn.textContent = \"+\";\n incrementBtn.setAttribute(\"aria-label\", \"Increase value\");\n\n const controls = document.createElement(\"div\");\n controls.classList.add(\"mljar-number-controls\");\n\n controls.appendChild(decrementBtn);\n controls.appendChild(incrementBtn);\n fieldRow.appendChild(input);\n fieldRow.appendChild(controls);\n\n container.appendChild(topLabel);\n container.appendChild(fieldRow);\n el.appendChild(container);\n\n function clamp(val, min, max) {\n if (Number.isFinite(min) && val < min) return min;\n if (Number.isFinite(max) && val > max) return max;\n return val;\n }\n\n function normalizeStep(step) {\n return Number.isFinite(step) && step > 0 ? step : 1;\n }\n\n function snapToStep(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = Math.round((value - base) / safeStep);\n const snapped = base + steps * safeStep;\n const precision = Math.max(\n 0,\n (String(safeStep).split(\".\")[1] || \"\").length\n );\n\n return Number(snapped.toFixed(precision + 2));\n }\n\n function isOnStepGrid(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = (value - base) / safeStep;\n const nearest = Math.round(steps);\n const epsilon = Math.max(1e-9, safeStep * 1e-9);\n\n return Math.abs(steps - nearest) <= epsilon;\n }\n\n function getCurrentBounds() {\n return {\n min: Number(model.get(\"min\")),\n max: Number(model.get(\"max\")),\n };\n }\n\n function isTransientDraft(raw) {\n return raw === \"\" || raw === \"-\" || raw === \".\" || raw === \"-.\";\n }\n\n let isEditing = false;\n const INPUT_COMMIT_DEBOUNCE_MS = 400;\n\n function clearPendingDraftCommit() {\n if (debounceTimer) clearTimeout(debounceTimer);\n pendingDraftValue = null;\n }\n\n function parseDraftValue(rawValue) {\n const raw = String(rawValue).trim();\n if (isTransientDraft(raw)) {\n return { kind: \"transient\" };\n }\n\n const value = Number(raw);\n if (!Number.isFinite(value)) {\n return { kind: \"invalid\" };\n }\n\n return { kind: \"number\", value };\n }\n\n function commitValue(nextValue, saveNow = true) {\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n const parsed = parseDraftValue(nextValue);\n if (parsed.kind !== \"number\") {\n syncFromModel();\n return;\n }\n\n let v = parsed.value;\n v = clamp(v, min, max);\n v = snapToStep(v, min, step);\n v = clamp(v, min, max);\n input.value = String(v);\n model.set(\"value\", v);\n\n if (saveNow) {\n model.save_changes();\n }\n }\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Enter number\";\n\n const min = Number(model.get(\"min\"));\n const max = Number(model.get(\"max\"));\n const step = Number(model.get(\"step\"));\n\n if (Number.isFinite(min)) input.min = String(min); else input.removeAttribute(\"min\");\n if (Number.isFinite(max)) input.max = String(max); else input.removeAttribute(\"max\");\n if (Number.isFinite(step)) input.step = String(step); else input.removeAttribute(\"step\");\n\n const v = Number(model.get(\"value\"));\n if (!isEditing) {\n input.value = Number.isFinite(v) ? String(v) : \"\";\n }\n\n const disabled = !!model.get(\"disabled\");\n input.disabled = disabled;\n incrementBtn.disabled = disabled;\n decrementBtn.disabled = disabled;\n\n const hidden = !!model.get(\"hidden\");\n container.style.display = hidden ? \"none\" : \"flex\";\n }\n\n let debounceTimer = null;\n let pendingDraftValue = null;\n input.addEventListener(\"focus\", () => {\n isEditing = true;\n });\n\n input.addEventListener(\"input\", () => {\n if (model.get(\"disabled\")) return;\n\n const parsed = parseDraftValue(input.value);\n if (parsed.kind !== \"number\") {\n clearPendingDraftCommit();\n return;\n }\n\n const v = parsed.value;\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n if (Number.isFinite(min) && v < min) {\n clearPendingDraftCommit();\n return;\n }\n if (Number.isFinite(max) && v > max) {\n clearPendingDraftCommit();\n return;\n }\n if (!isOnStepGrid(v, min, step)) {\n clearPendingDraftCommit();\n return;\n }\n\n pendingDraftValue = v;\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n if (pendingDraftValue === null) return;\n model.set(\"value\", pendingDraftValue);\n model.save_changes();\n pendingDraftValue = null;\n }, INPUT_COMMIT_DEBOUNCE_MS);\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n clearPendingDraftCommit();\n commitValue(input.value, true);\n });\n\n input.addEventListener(\"keydown\", event => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n input.blur();\n }\n });\n\n incrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base + step, min, step));\n });\n\n decrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base - step, min, step));\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:step\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }*/\n }\n export default { render };\n ", "_model_module": "anywidget", "_model_module_version": "~0.11.*", "_model_name": "AnyModel", "_view_count": null, "_view_module": "anywidget", "_view_module_version": "~0.11.*", "_view_name": "AnyView", "cell_id": "", "disabled": false, "hidden": false, "label": "Genesys ramp — licence-free months", "layout": "IPY_MODEL_d9afd02316174736b31a7964fe071605", "layout_path": null, "max": 24.0, "min": 0.0, "position": "inline", "render_slot_id": null, "source_cell_id": null, "step": 1.0, "tabbable": null, "tooltip": null, "url_key": "", "value": 6.0 } }, "de9d84197e314233938eb4ad7b19ad0b": { "model_module": "@jupyter-widgets/base", "model_module_version": "2.0.0", "model_name": "LayoutModel", "state": { "_model_module": "@jupyter-widgets/base", "_model_module_version": "2.0.0", "_model_name": "LayoutModel", "_view_count": null, "_view_module": "@jupyter-widgets/base", "_view_module_version": "2.0.0", "_view_name": "LayoutView", "align_content": null, "align_items": null, "align_self": null, "border_bottom": null, "border_left": null, "border_right": null, "border_top": null, "bottom": null, "display": null, "flex": null, "flex_flow": null, "grid_area": null, "grid_auto_columns": null, "grid_auto_flow": null, "grid_auto_rows": null, "grid_column": null, "grid_gap": null, "grid_row": null, "grid_template_areas": null, "grid_template_columns": null, "grid_template_rows": null, "height": null, "justify_content": null, "justify_items": null, "left": null, "margin": null, "max_height": null, "max_width": null, "min_height": null, "min_width": null, "object_fit": null, "object_position": null, "order": null, "overflow": null, "padding": null, "right": null, "top": null, "visibility": null, "width": null } }, "e7597a4eb1c14475a440ac83ee50ce1c": { "model_module": "@jupyter-widgets/base", "model_module_version": "2.0.0", "model_name": "LayoutModel", "state": { "_model_module": "@jupyter-widgets/base", "_model_module_version": "2.0.0", "_model_name": "LayoutModel", "_view_count": null, "_view_module": "@jupyter-widgets/base", "_view_module_version": "2.0.0", "_view_name": "LayoutView", "align_content": null, "align_items": null, "align_self": null, "border_bottom": null, "border_left": null, "border_right": null, "border_top": null, "bottom": null, "display": null, "flex": null, "flex_flow": null, "grid_area": null, "grid_auto_columns": null, "grid_auto_flow": null, "grid_auto_rows": null, "grid_column": null, "grid_gap": null, "grid_row": null, "grid_template_areas": null, "grid_template_columns": null, "grid_template_rows": null, "height": null, "justify_content": null, "justify_items": null, "left": null, "margin": null, "max_height": null, "max_width": null, "min_height": null, "min_width": null, "object_fit": null, "object_position": null, "order": null, "overflow": null, "padding": null, "right": null, "top": null, "visibility": null, "width": null } }, "f1dd0e56a7224aafa77c1c3c23e9fdb0": { "model_module": "@jupyter-widgets/controls", "model_module_version": "2.0.0", "model_name": "HTMLStyleModel", "state": { "_model_module": "@jupyter-widgets/controls", "_model_module_version": "2.0.0", "_model_name": "HTMLStyleModel", "_view_count": null, "_view_module": "@jupyter-widgets/base", "_view_module_version": "2.0.0", "_view_name": "StyleView", "background": null, "description_width": "", "font_size": null, "text_color": null } } }, "version_major": 2, "version_minor": 0 } } }, "nbformat": 4, "nbformat_minor": 5 }