|
7876 | 7876 | progress: { apps: 0, time: 0, tickets: 0 }, |
7877 | 7877 | envProgress: { appsProcessed: 0 }, |
7878 | 7878 | calcInputs: { |
7879 | | - users: 5000, endpoints: 6000, envs: 3, apps: 150, customApps: 30, |
| 7879 | + users: 5000, endpoints: 6000, envs: 3, apps: 150, customApps: 25, |
7880 | 7880 | monthlyTickets: 600, avgTicketMins: 18, pkgHours: 80, patchHours: 60, |
7881 | 7881 | openReqs: 0, delayedInitiatives: 0, regulated: false, |
7882 | 7882 | complexMixPct: 20, loadedRate: 95, |
7883 | 7883 | /* Currency mode (W2/W3). catalogCovered / configCovered stay null |
7884 | 7884 | until the AE enters a real Setup Store cross-reference. */ |
7885 | 7885 | appsTotal: 150, appsHighChurn: 23, cycleDaysNow: 14, pctAging60Now: 35, |
7886 | | - catalogCovered: null, configCovered: null |
| 7886 | + catalogCovered: null, configCovered: null, |
| 7887 | + /* Invariant rates. Hours and custom-app counts are always derived |
| 7888 | + from these × the current estate, never scaled incrementally. */ |
| 7889 | + pkgHrsPerApp: 80 / 150, patchHrsPerApp: 60 / 150, customSharePct: 25 / 150 |
7887 | 7890 | }, |
7888 | 7891 | impactMode: 'effort', |
7889 | 7892 | exOpen: {}, |
@@ -13903,27 +13906,9 @@ <h2 class="text-xl font-bold tracking-tight text-[#1A1A2E]">Ask a Question</h2> |
13903 | 13906 | </div> |
13904 | 13907 | ${idx < arr.length - 1 ? '<div class="lifecycle-connector ' + connClass + '"></div>' : ''} |
13905 | 13908 | `;}).join('')} |
13906 | | - <!-- Flow controls (icon-only) --> |
13907 | | - ${(STATE.solutionViewUser || STATE.solutionViewAdmin || STATE.solutionViewSetupStore || STATE.solutionViewMultiTenant || STATE.solutionViewDashboard || STATE.solutionViewAi || STATE.solutionViewOnboarding || STATE.solutionViewUpdate) ? (() => { |
13908 | | - const _running = |
13909 | | - (STATE.solutionViewSetupStore && STATE.ssRunning && !STATE.ssPaused) || |
13910 | | - (STATE.solutionViewMultiTenant && STATE.mtRunning && !STATE.mtPaused) || |
13911 | | - (STATE.solutionViewDashboard && STATE.drRunning && !STATE.drPaused) || |
13912 | | - (STATE.solutionViewUpdate && STATE.updRunning && !STATE.updPaused) || |
13913 | | - (STATE.solutionViewAi && STATE.aiRunning && !STATE.aiPaused) || |
13914 | | - (STATE.solutionViewOnboarding && STATE.obRunning && !STATE.obPaused) || |
13915 | | - (STATE.solutionViewAdmin && STATE.adminPane === 'ship' && STATE.updRunning && !STATE.updPaused) || |
13916 | | - ((STATE.solutionViewUser || (STATE.solutionViewAdmin && STATE.adminPane !== 'ship')) && STATE.flowRunning && !STATE.flowPaused); |
13917 | | - const _label = _running ? 'Pause' : 'Play'; |
13918 | | - const _icon = _running ? 'pause' : 'play'; |
13919 | | - return ` |
13920 | | - <div class="flex items-center gap-1.5 ml-auto pl-2"> |
13921 | | - <button type="button" data-action="toggle-flow" title="${_label}" aria-label="${_label}" class="bg-[#0000CC] text-white w-8 h-8 rounded-full hover:bg-[#0000A8] inline-flex items-center justify-center transition shadow-sm"><i data-lucide="${_icon}" class="w-3.5 h-3.5"></i></button> |
13922 | | - <button type="button" data-action="step-flow" title="Step" aria-label="Step" class="bg-white text-[#0000CC] border border-[#0000CC] w-8 h-8 rounded-full hover:bg-[#F6F8FF] inline-flex items-center justify-center transition"><i data-lucide="step-forward" class="w-3.5 h-3.5"></i></button> |
13923 | | - <button type="button" data-action="reset-flow" title="Reset" aria-label="Reset" class="bg-white text-[#1A1A2E] border border-[#1A1A2E]/30 w-8 h-8 rounded-full hover:bg-slate-50 hover:border-[#1A1A2E] inline-flex items-center justify-center transition"><i data-lucide="rotate-ccw" class="w-3.5 h-3.5"></i></button> |
13924 | | - </div> |
13925 | | - `; |
13926 | | - })() : ''} |
| 13909 | + <!-- Play / Step / Reset live only in the pop-out header. The rail's |
| 13910 | + copy rendered under exactly the same condition as the modal, so |
| 13911 | + both clusters were always on screen at once. --> |
13927 | 13912 | </div> |
13928 | 13913 | </div> |
13929 | 13914 | </div> |
@@ -14417,6 +14402,7 @@ <h2 class="text-xl font-bold tracking-tight text-[#1A1A2E]">Ask a Question</h2> |
14417 | 14402 | a driver of every hours figure on the page. */ |
14418 | 14403 | const PACKAGING_BENCHMARKS = { |
14419 | 14404 | envRepeatShare: 0.60, |
| 14405 | + cycleGateDays: PT_LADDER.speed.rows.reduce((s, r) => s + r[2], 0), |
14420 | 14406 | awStdHrs: 0.21, |
14421 | 14407 | awComplexHrs: 3.0, |
14422 | 14408 | tradStdHrs: 4.33, |
@@ -14463,6 +14449,29 @@ <h2 class="text-xl font-bold tracking-tight text-[#1A1A2E]">Ask a Question</h2> |
14463 | 14449 | else { ci.endpoints = Math.round(n * 1.3); ci.envs = 6; ci.apps = 250; ci.customApps = 50; ci.monthlyTickets = Math.round(n * 0.12); ci.avgTicketMins = 20; ci.pkgHours = 160; ci.patchHours = 110; } |
14464 | 14450 | ci.appsTotal = ci.apps; |
14465 | 14451 | ci.appsHighChurn = Math.round(ci.apps * 0.15); |
| 14452 | + /* Record the band's implied per-app rates. Editing the application |
| 14453 | + count then rescales hours from these, so the two inputs stay |
| 14454 | + consistent in either order and at any typing speed. */ |
| 14455 | + const apps = Math.max(1, ci.apps); |
| 14456 | + ci.pkgHrsPerApp = ci.pkgHours / apps; |
| 14457 | + ci.patchHrsPerApp = ci.patchHours / apps; |
| 14458 | + ci.customSharePct = ci.customApps / apps; |
| 14459 | + } |
| 14460 | + |
| 14461 | + /* Single place that turns an application count into the dependent |
| 14462 | + figures. Absolute, idempotent, and safe to call on every keystroke. */ |
| 14463 | + function applyAppCount(n){ |
| 14464 | + const ci = STATE.calcInputs; |
| 14465 | + const apps = Math.max(1, Math.min(100000, Math.round(n))); |
| 14466 | + if (!(ci.pkgHrsPerApp > 0)) ci.pkgHrsPerApp = (ci.pkgHours || 0) / Math.max(1, ci.apps); |
| 14467 | + if (!(ci.patchHrsPerApp > 0)) ci.patchHrsPerApp = (ci.patchHours || 0) / Math.max(1, ci.apps); |
| 14468 | + if (ci.customSharePct == null) ci.customSharePct = (ci.customApps || 0) / Math.max(1, ci.apps); |
| 14469 | + ci.apps = apps; |
| 14470 | + ci.appsTotal = apps; |
| 14471 | + ci.appsHighChurn = Math.round(apps * 0.15); |
| 14472 | + ci.customApps = Math.max(0, Math.min(apps, Math.round(apps * ci.customSharePct))); |
| 14473 | + ci.pkgHours = Math.round(apps * ci.pkgHrsPerApp * 10) / 10; |
| 14474 | + ci.patchHours = Math.round(apps * ci.patchHrsPerApp * 10) / 10; |
14466 | 14475 | } |
14467 | 14476 |
|
14468 | 14477 | /* ═══════════════════════════════════════════════════════════════════ |
@@ -15699,7 +15708,8 @@ <h2 class="text-xl font-bold tracking-tight text-[#1A1A2E]">Ask a Question</h2> |
15699 | 15708 | const tAw = rows.reduce((s, x) => s + x.awR, 0); |
15700 | 15709 | const monthlyReclaim = Math.max(0, tNow - tAw); |
15701 | 15710 | const annualValue = monthlyReclaim * 12 * rate; |
15702 | | - const fte = monthlyReclaim * 12 / 1920; |
| 15711 | + const fteHours = ROI_ASSUMPTIONS[STATE.calcScenario].fteHours || 1920; |
| 15712 | + const fte = fteHours > 0 ? monthlyReclaim * 12 / fteHours : 0; |
15703 | 15713 | const oneTime = r.cycleDollarsSaved || 0; |
15704 | 15714 | const invest = r.awAnnualInvestment || 0; |
15705 | 15715 | /* Break-even and the three-year net are recurring-only: the one-time |
@@ -16048,6 +16058,10 @@ <h2 class="text-xl font-bold tracking-tight text-[#1A1A2E]">Ask a Question</h2> |
16048 | 16058 | 'Applied to packaging and update-validation hours and to the one-time rebuild figure. Discovery, rationalisation and licence review are counted once.'], |
16049 | 16059 | ['Update-workload weighting', '12 updates a year for monthly-or-faster titles, 3 for the remainder', |
16050 | 16060 | 'Modeling convention, stated so the weighted view can be checked.'], |
| 16061 | + ['Application estate', num(ci.apps) + ' applications at ' + (ci.pkgHrsPerApp || 0).toFixed(2) + ' packaging and ' + (ci.patchHrsPerApp || 0).toFixed(2) + ' update-validation hours each a month, per environment', |
| 16062 | + 'Per-application rates from the employee band. Change the application count and hours follow it; change the hours and the rate follows them.'], |
| 16063 | + ['Release gate', pb.cycleGateDays.toFixed(1) + ' days minimum from request to deployed', |
| 16064 | + 'Elapsed time is not touch time. This is the delivery ladder’s full post-automation residual — intake and sourcing, packaging and detection logic, the approval queue, and publish and verify — the elapsed time that remains once the effort is automated. The modelled cycle is floored there rather than scaled to a fraction of a day.'], |
16051 | 16065 | ['Exposure figures', 'Mean time to exploit \u22127 days in 2026, from +63 days in 2018; 48,185 CVEs published in 2025, up 20.6% year over year', |
16052 | 16066 | 'Mandiant M-Trends 2026; CVE program data 2025\u20132026. Shown in full under Current State.'], |
16053 | 16067 | ]; |
@@ -16809,7 +16823,9 @@ <h2 class="cs-panel-title flex-1">${ent ? 'Consolidating application delivery' : |
16809 | 16823 | // the packaging card uses (PACKAGING_BENCHMARKS at the entered mix). |
16810 | 16824 | const cycleDaysNow = Math.max(0, ci.cycleDaysNow ?? 14); |
16811 | 16825 | const cycleRatio = tradCycleHours > 0 ? (awCycleHours / tradCycleHours) : 0; |
16812 | | - const cycleDaysModelled = cycleDaysNow > 0 ? Math.max(0.5, Math.round(cycleDaysNow * cycleRatio * 10) / 10) : 0; |
| 16826 | + const cycleDaysModelled = cycleDaysNow > 0 |
| 16827 | + ? Math.min(cycleDaysNow, Math.max(pb.cycleGateDays, Math.round(cycleDaysNow * cycleRatio * 10) / 10)) |
| 16828 | + : 0; |
16813 | 16829 | // Projected aging: automated pipelines are assumed to clear their |
16814 | 16830 | // updates inside 60 days, so aging persists only in the workload that |
16815 | 16831 | // stays manual. The assumption is stated on screen. |
@@ -17453,17 +17469,17 @@ <h2 id="cs-editor-title" class="text-xl font-semibold tracking-normal">Edit Narr |
17453 | 17469 | if (e.target?.id === 'roi-employees') { const n = cleanNum(e.target.value); if (n > 0) { deriveFromEmployees(n); calculateImpact(); clearTimeout(_roiDebounce); _roiDebounce = setTimeout(() => render(), 600); } return; } |
17454 | 17470 | if (e.target?.id === 'roi-users') { STATE.calcInputs.users = cleanNum(e.target.value); return; } |
17455 | 17471 | if (e.target?.id === 'roi-endpoints') { STATE.calcInputs.endpoints = cleanNum(e.target.value); return; } |
17456 | | - if (e.target?.id === 'roi-envs') { STATE.calcInputs.envs = cleanNum(e.target.value); return; } |
17457 | | - if (e.target?.id === 'roi-apps') { STATE.calcInputs.apps = cleanNum(e.target.value); return; } |
17458 | | - if (e.target?.id === 'roi-custom') { STATE.calcInputs.customApps = cleanNum(e.target.value); return; } |
| 17472 | + if (e.target?.id === 'roi-envs') { STATE.calcInputs.envs = Math.max(1, Math.min(12, cleanNum(e.target.value))); return; } |
| 17473 | + if (e.target?.id === 'roi-apps') { applyAppCount(Math.max(1, cleanNum(e.target.value))); return; } |
| 17474 | + if (e.target?.id === 'roi-custom') { const ci = STATE.calcInputs; ci.customApps = Math.max(0, Math.min(ci.apps, cleanNum(e.target.value))); ci.customSharePct = ci.customApps / Math.max(1, ci.apps); return; } |
17459 | 17475 | if (e.target?.id === 'roi-tickets') { STATE.calcInputs.monthlyTickets = cleanNum(e.target.value); return; } |
17460 | 17476 | if (e.target?.id === 'roi-ticketmins') { STATE.calcInputs.avgTicketMins = cleanNum(e.target.value); return; } |
17461 | | - if (e.target?.id === 'roi-pkghrs') { STATE.calcInputs.pkgHours = cleanNum(e.target.value); return; } |
17462 | | - if (e.target?.id === 'roi-patchhrs') { STATE.calcInputs.patchHours = cleanNum(e.target.value); return; } |
| 17477 | + if (e.target?.id === 'roi-pkghrs') { const ci = STATE.calcInputs; ci.pkgHours = Math.max(0, cleanNum(e.target.value)); ci.pkgHrsPerApp = ci.pkgHours / Math.max(1, ci.apps); return; } |
| 17478 | + if (e.target?.id === 'roi-patchhrs') { const ci = STATE.calcInputs; ci.patchHours = Math.max(0, cleanNum(e.target.value)); ci.patchHrsPerApp = ci.patchHours / Math.max(1, ci.apps); return; } |
17463 | 17479 | if (e.target?.id === 'roi-reqs') { STATE.calcInputs.openReqs = cleanNum(e.target.value); return; } |
17464 | 17480 | if (e.target?.id === 'roi-initiatives') { STATE.calcInputs.delayedInitiatives = cleanNum(e.target.value); return; } |
17465 | | - if (e.target?.id === 'hero-envs') { STATE.calcInputs.envs = Math.max(1, cleanNum(e.target.value)); _curRecalc(); return; } |
17466 | | - if (e.target?.id === 'hero-apps') { const ci = STATE.calcInputs; const n = Math.max(1, cleanNum(e.target.value)); const k = n / Math.max(1, ci.apps); ci.apps = n; ci.appsTotal = n; ci.appsHighChurn = Math.round(n * 0.15); ci.customApps = Math.max(0, Math.round(ci.customApps * k)); ci.pkgHours = Math.max(0, Math.round(ci.pkgHours * k)); ci.patchHours = Math.max(0, Math.round(ci.patchHours * k)); _curRecalc(); return; } |
| 17481 | + if (e.target?.id === 'hero-envs') { STATE.calcInputs.envs = Math.max(1, Math.min(12, cleanNum(e.target.value))); _curRecalc(); return; } |
| 17482 | + if (e.target?.id === 'hero-apps') { applyAppCount(Math.max(1, cleanNum(e.target.value))); _curRecalc(); return; } |
17467 | 17483 | if (e.target?.id === 'cur-apps-total') { STATE.calcInputs.appsTotal = Math.max(1, cleanNum(e.target.value)); _curRecalc(); return; } |
17468 | 17484 | if (e.target?.id === 'cur-apps-churn') { STATE.calcInputs.appsHighChurn = Math.max(0, cleanNum(e.target.value)); _curRecalc(); return; } |
17469 | 17485 | if (e.target?.id === 'cur-cycle-days') { STATE.calcInputs.cycleDaysNow = Math.max(0, cleanNum(e.target.value)); _curRecalc(); return; } |
|
0 commit comments