1/* ========================================================================= 2 suite.js â shared helpers for the manufacturing calculator suite. 3 Loaded by safety-stock.html + reorder-point.html. 4 ========================================================================= */ 5const $ = id => document.getElementById(id); 6const fmt = (n, dec=0) => Number(n).toLocaleString('en-US',{minimumFractionDigits:dec,maximumFractionDigits:dec}); 7 8/* capture once at load: compute()'s shareable-URL replaceState strips ?selfcheck 9 from the address bar before selfCheckBanner() would read it â so read it here first */ 10const HAS_SELFCHECK = new URLSearchParams(location.search).has('selfcheck'); 11 12/* GA4 is deliberately dormant until a real Measurement ID is added. 13 Events never include calculator inputs or CSV contents. */ 14const ANALYTICS = { 15 ga4_id: "G-NX4082PQF5" 16}; 17(function initAnalytics(){ 18 if(!/^G-[A-Z0-9]+$/i.test(ANALYTICS.ga4_id)) return; 19 window.dataLayer=window.dataLayer||[]; 20 window.gtag=window.gtag||function(){ window.dataLayer.push(arguments); }; 21 window.gtag('js',new Date()); 22 window.gtag('config',ANALYTICS.ga4_id); 23 const s=document.createElement('script'); 24 s.async=true; 25 s.src='https://www.googletagmanager.com/gtag/js?id='+encodeURIComponent(ANALYTICS.ga4_id); 26 document.head.appendChild(s); 27})(); 28function trackEvent(name, params={}){ 29 if(HAS_SELFCHECK||typeof window.gtag!=='function') return; 30 window.gtag('event',name,Object.assign({calculator:location.pathname.split('/').pop()||'epq'},params)); 31} 32 33/* self-referencing canonical: works on any domain + strips ?query so the 34 shareable-URL variants don't get indexed as duplicate pages */ 35(function fixCanonical(){ 36 var u=location.origin+location.pathname.replace(/\/index\.html$/,'/'); 37 var c=document.querySelector('link[rel="canonical"]'); if(c) c.href=u; 38 var o=document.querySelector('meta[property="og:url"]'); 39 if(!o){ o=document.createElement('meta'); o.setAttribute('property','og:url'); document.head.appendChild(o); } 40 o.setAttribute('content',u); 41})(); 42 43/* --- query-string value passing (the "integrated suite" wiring) --- */ 44const PARAMS = new URLSearchParams(location.search); 45/* read a numeric param; returns null if absent/blank so callers can fall back */ 46function param(key){ 47 if(!PARAMS.has(key)) return null; 48 const v = parseFloat(PARAMS.get(key)); 49 return isFinite(v) ? v : null; 50} 51/* build "page?key=val..." dropping null/empty values, rounding for clean URLs */ 52function suiteLink(page, vals){ 53 const q = Object.entries(vals) 54 .filter(([,v]) => v!=null && isFinite(v)) 55 .map(([k,v]) => k+'='+encodeURIComponent(+(+v).toFixed(4))) 56 .join('&'); 57 return page + (q ? '?'+q : ''); 58} 59 60/* --- inverse normal CDF (probit) â Z from a service level probability. 61 Acklam's rational approximation, abs error < 1.2e-9 on (0,1). 62 This is the "full Z-score" edge: user types a service level, not a Z. --- */ 63function invNorm(p){ 64 if(p<=0||p>=1) return NaN; 65 const a=[-3.969683028665376e+01,2.209460984245205e+02,-2.759285104469687e+02,1.383577518672690e+02,-3.066479806614716e+01,2.506628277459239e+00]; 66 const b=[-5.447609879822406e+01,1.615858368580409e+02,-1.556989798598866e+02,6.680131188771972e+01,-1.328068155288572e+01]; 67 const c=[-7.784894002430293e-03,-3.223964580411365e-01,-2.400758277161838e+00,-2.549732539343734e+00,4.374664141464968e+00,2.938163982698783e+00]; 68 const d=[7.784695709041462e-03,3.224671290700398e-01,2.445134137142996e+00,3.754408661907416e+00]; 69 const plow=0.02425, phigh=1-plow; let q,r; 70 if(p<plow){ q=Math.sqrt(-2*Math.log(p)); return (((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5])/((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1); } 71 if(p<=phigh){ q=p-0.5; r=q*q; return (((((a[0]*r+a[1])*r+a[2])*r+a[3])*r+a[4])*r+a[5])*q/(((((b[0]*r+b[1])*r+b[2])*r+b[3])*r+b[4])*r+1); } 72 q=Math.sqrt(-2*Math.log(1-p)); return -(((((c[0]*q+c[1])*q+c[2])*q+c[3])*q+c[4])*q+c[5])/((((d[0]*q+d[1])*q+d[2])*q+d[3])*q+1); 73} 74 75/* --- suite nav: highlight the current page --- */
76function renderSuiteNav(current){ 77 const pages=[ 78 {id:'epq', href:'index.html', step:'1', label:'EPQ'}, 79 {id:'ss', href:'safety-stock.html', step:'2', label:'Safety Stock'}, 80 {id:'rop', href:'reorder-point.html', step:'3', label:'Reorder Point'}, 81 ]; 82 const el=$('suiteNav'); if(!el) return; 83 el.innerHTML=pages.map(p=> 84 '<a href="'+p.href+'" class="'+(p.id===current?'active':'')+'"><span class="step">'+p.step+'</span>'+p.label+'</a>' 85 ).join(''); 86} 87 88/* F6 â trust / credibility strip (honest E-E-A-T + privacy signals; all client-side so "data never leaves your device" is literally true) */ 89const TRUST = ['Full formula â not a simplified version','Free · no signup','Runs in your browser','Your numbers never leave your device']; 90function renderTrust(elId){ 91 const el=$(elId); if(!el) return; 92 el.innerHTML=TRUST.map(t=>'<span>'+t+'</span>').join(''); 93} 94 95/* Honest ownership + references. Shared so every calculator carries the same 96 trust standard without duplicating copy across three HTML files. */ 97const CREDIBILITY = { 98 epq:{ 99 summary:'Created and maintained by Sathapana as a free planning utility for manufacturers, operations teams and students. The formula, assumptions and cost breakdown are visible on the page.', 100 sources:[ 101 {label:'Economic Production Quantity model â Bradley University',href:'https://personalpages.bradley.edu/~rf/EPQ.htm'} 102 ] 103 }, 104 ss:{ 105 summary:'Created and maintained by Sathapana. This calculator uses the independent demand-and-lead-time variability model and converts the selected cycle service level to a Z-score.', 106 sources:[ 107 {label:'Safety stock under demand and lead-time variability â MIT',href:'https://web.mit.edu/2.810/www/files/readings/King_SafetyStock.pdf'}, 108 {label:'Safety stock analysis tutorial â NC State University',href:'https://scm.ncsu.edu/scm-articles/article/safety-stock-analysis-inventory-management-models-a-tutorial'} 109 ] 110 }, 111 rop:{ 112 summary:'Created and maintained by Sathapana. Reorder point is shown as expected demand during lead time plus the safety-stock buffer carried from the previous step.', 113 sources:[ 114 {label:'Safety stock analysis tutorial â NC State University',href:'https://scm.ncsu.edu/scm-articles/article/safety-stock-analysis-inventory-management-models-a-tutorial'}, 115 {label:'Safety stock under demand and lead-time variability â MIT',href:'https://web.mit.edu/2.810/www/files/readings/King_SafetyStock.pdf'} 116 ] 117 } 118}; 119function renderCredibility(current){ 120 const el=$('credibility'), c=CREDIBILITY[current]; if(!el||!c) return; 121 el.innerHTML='<div><div class="cred-kicker">About this tool</div><h2>Transparent method, no black box</h2>' 122 +'<p>'+c.summary+'</p><p class="cred-meta">Last reviewed July 15, 2026 · Free · No signup · Calculations run locally</p></div>' 123 +'<div><div class="cred-label">Method references</div><ul>' 124 +c.sources.map(s=>'<li><a href="'+s.href+'" target="_blank" rel="noopener noreferrer">'+s.label+'</a></li>').join('') 125 +'</ul></div>'; 126} 127 128/* F2 â related-tools cluster: real topical cross-links (Omni links its EOQ page to a rent calculator; we link the actual suite) */ 129const SUITE_TOOLS = { 130 epq:{href:'index.html', step:'Step 1', title:'EPQ Calculator', desc:'Optimal production lot size â full formula, multi-SKU CSV batch and Excel export.'}, 131 ss: {href:'safety-stock.html', step:'Step 2', title:'Safety Stock', desc:'Z-score buffer from your service level, covering demand and lead-time variability.'}, 132 rop:{href:'reorder-point.html', step:'Step 3', title:'Reorder Point', desc:'When to reorder: demand during lead time plus your safety stock, with days of cover.'} 133}; 134function renderRelated(current){ 135 const el=$('related'); if(!el) return; 136 const others=Object.keys(SUITE_TOOLS).filter(k=>k!==current); 137 el.innerHTML='<div class="rh">Related tools in this suite</div><div class="rgrid">' 138 +others.map(k=>{ const p=SUITE_TOOLS[k]; 139 return '<a href="'+p.href+'"><div class="rt">'+p.title+'<span class="st">'+p.step+'</span></div>' 140 +'<div class="rd">'+p.desc+'</div><span class="rarr">Open â</span></a>'; }).join('') 141 +'</div>'; 142} 143 144function showErr(id,m){ const e=$(id); e.textContent=m; e.classList.add('show'); } 145function hideErr(id){ $(id).classList.remove('show'); } 146 147/* shared self-check for the Z-score helper (pages add their own asserts) */ 148function invNormSelfChecks(assert){ 149 assert(Math.abs(invNorm(0.90)-1.2816)<2e-3, 'invNorm(0.90) â 1.2816'); 150 assert(Math.abs(invNorm(0.95)-1.6449)<2e-3, 'invNorm(0.95) â 1.6449 (got '+invNorm(0.95).toFixed(4)+')'); 151 assert(Math.abs(invNorm(0.975)-1.9600)<2e-3,'invNorm(0.975) â 1.9600'); 152 assert(Math.abs(invNorm(0.99)-2.3263)<2e-3, 'invNorm(0.99) â 2.3263'); 153 assert(Math.abs(invNorm(0.5))<1e-6, 'invNorm(0.50) â 0'); 154 assert(isNaN(invNorm(0))&&isNaN(invNorm(1)),'invNorm rejects p=0 and p=1'); 155} 156 157/* render a pass/fail banner for ?selfcheck=1 */ 158function selfCheckBanner(run){ 159 if(!HAS_SELFCHECK) return; 160 const out=[]; let pass=true; 161 const assert=(c,m)=>{ if(!c)pass=false; out.push((c?'â ':'â ')+m); }; 162 run(assert); 163 console.log('%cself-check â '+(pass?'ALL PASS':'FAIL'),'font-weight:bold;color:'+(pass?'#2C8A68':'#A23B2B')); 164 out.forEach(l=>console.log(l)); 165 const bar=document.createElement('div'); 166 bar.textContent=(pass?'â self-check: ALL PASS':'â self-check: FAIL â see console')+' ('+out.length+' assertions)'; 167 bar.style.cssText='position:fixed;top:0;left:0;right:0;z-index:9999;padding:8px 14px;font:600 13px monospace;color:#fff;background:'+(pass?'#2C8A68':'#A23B2B'); 168 document.body.appendChild(bar); 169 return {pass,out}; 170} 171 172/* ========================================================================= 173 P2 â shareable URL + monetization (config-driven, tasteful, tool-first) 174 ========================================================================= */ 175/* reflect current inputs in the address bar so the link is copy-pasteable */ 176function syncURL(vals){ 177 const q=Object.entries(vals).filter(([,v])=>v!=null&&v!==''&&isFinite(v)) 178 .map(([k,v])=>k+'='+encodeURIComponent(+(+v).toFixed(4))).join('&'); 179 history.replaceState(null,'',location.pathname+(q?'?'+q:'')); 180} 181/* copy current URL â clipboard API w/ execCommand fallback (works off https too) */ 182function wireCopyLink(btnId){ 183 const b=$(btnId); if(!b) return; 184 b.addEventListener('click',async()=>{ 185 const url=location.href; 186 try{ await navigator.clipboard.writeText(url); } 187 catch(e){ const t=document.createElement('textarea'); t.value=url; document.body.appendChild(t); t.select(); try{document.execCommand('copy');}catch(_){} t.remove(); } 188 const old=b.dataset.label||b.textContent; b.dataset.label=old; b.textContent='â Link copied'; 189 trackEvent('share_result'); 190 setTimeout(()=>{ b.textContent=old; },1500); 191 }); 192} 193 194/* monetization config â fill these in to go live (see CLAUDE.md P2 notes) */ 195const MONETIZE = { 196 affiliate_url: "", // TODO: ERP/MES affiliate link (empty â CTA renders nothing) 197 affiliate_label: "See ERP/MES options", 198 adsense_client: "", // TODO: "ca-pub-XXXXXXXXXXXXXXXX" (empty â ad slot stays hidden) 199 adsense_slot: "" // TODO: ad unit slot id 200}; 201/* tasteful affiliate CTA under the results â "automate this in your ERP" */
202function renderAffiliateCTA(elId, line){ 203 const el=$(elId); if(!el) return; 204 if(!MONETIZE.affiliate_url){ el.innerHTML=''; return; } // dormant â render nothing until a real affiliate link is set (no dead CTA) 205 el.innerHTML='<div class="cta-erp"><div class="cta-tx"><b>â¡ Automate this in your ERP</b>' 206 +'<span>'+line+'</span></div>' 207 +'<a class="cta-btn" href="'+MONETIZE.affiliate_url+'" target="_blank" rel="sponsored noopener noreferrer">'+MONETIZE.affiliate_label+' â</a></div>'; 208} 209 210/* ========================================================================= 211 Directory "Featured on" badges â some high-DA directories (Turbo0, 212 findly.tools, Wired Businessâ¦) grant a dofollow listing only if you display 213 their badge. Register â paste the badge parts here â redeploy â they verify. 214 Empty array â renders nothing (same dormant pattern as the affiliate CTA). 215 Keep width/height per badge so lazy-loaded images don't cause layout shift. 216 ========================================================================= */ 217const BADGES = [ 218 { href:'https://findly.tools/epq-calculator', img:'https://findly.tools/badges/findly-tools-badge-light.svg', imgDark:'https://findly.tools/badges/findly-tools-badge-dark.svg', alt:'Featured on Findly.tools', width:139, height:44 }, 219 { href:'https://turbo0.com/item/epq-calculator', img:'https://img.turbo0.com/badge-listed-light.svg', imgDark:'https://img.turbo0.com/badge-listed-dark.svg', alt:'Listed on Turbo0', width:162, height:54 }, 220 { href:'https://wired.business', img:'https://wired.business/badge0-l
220ight.svg', imgDark:'https://wired.business/badge0-dark.svg', alt:'Featured on Wired Business', width:200, height:54 }, 221 // add more: { href, img, imgDark, alt, width, height } â imgDark optional (falls back to img) 222]; 223function renderBadges(elId){ 224 const el=$(elId); if(!el) return; 225 const paint=()=>{ 226 if(!BADGES.length){ el.innerHTML=''; return; } // dormant â nothing until a badge is added 227 const dark = document.documentElement.dataset.theme==='dark'; 228 el.innerHTML='<span class="badges-label">Featured on</span>' 229 +BADGES.map(b=>'<a href="'+b.href+'" target="_blank" rel="noopener">' 230 +'<img src="'+((dark&&b.imgDark)?b.imgDark:b.img)+'" alt="'+b.alt+'" loading="lazy" width="'+b.width+'" height="'+b.height+'"></a>').join(''); 231 }; 232 paint(); 233 window.addEventListener('themechange', paint); // swap light/dark badge art on toggle 234} 235 236/* ========================================================================= 237 Dark mode â flips <html data-theme>. An early inline <head> script sets the 238 initial theme before paint (no flash); this only handles the toggle + persist. 239 ========================================================================= */ 240function applyTheme(t){ 241 document.documentElement.dataset.theme = t; 242 try{ localStorage.setItem('theme', t); }catch(e){} 243 const b=$('themeToggle'); 244 if(b){ b.textContent = t==='dark' ? 'â' : 'â¾'; b.setAttribute('aria-label', t==='dark'?'Switch to light mode':'Switch to dark mode'); } 245 window.dispatchEvent(new Event('themechange')); // pages with a canvas (index) redraw on this 246} 247function renderThemeToggle(){ 248 if($('themeToggle')) return; 249 const cur = document.documentElement.dataset.theme || 'light'; 250 const b=document.createElement('button'); 251 b.id='themeToggle'; b.className='theme-toggle'; b.type='button'; 252 b.textContent = cur==='dark' ? 'â' : 'â¾'; 253 b.setAttribute('aria-label', cur==='dark'?'Switch to light mode':'Switch to dark mode'); 254 b.addEventListener('click',()=>applyTheme((document.documentElement.dataset.theme==='dark')?'light':'dark')); 255 document.body.appendChild(b); 256} 257renderThemeToggle(); // every page loads suite.js at end of <body> â DOM is ready 258 259/* self-check for the theme system (called from index's ?selfcheck=1) */ 260function themeSelfChecks(assert){ 261 const prev=document.documentElement.dataset.theme; 262 const savedLS=(function(){try{return localStorage.getItem('theme');}catch(e){return null;}})(); 263 applyTheme('dark'); 264 const darkBg=getComputedStyle(document.body).backgroundColor; 265 assert(document.documentElement.dataset.theme==='dark','applyTheme("dark") sets data-theme=dark'); 266 applyTheme('light'); 267 const lightBg=getComputedStyle(document.body).backgroundColor; 268 assert(document.documentElement.dataset.theme==='light','applyTheme("light") sets data-theme=light'); 269 assert(darkBg!==lightBg,'dark and light themes render different backgrounds ('+lightBg+' vs '+darkBg+')'); 270 applyTheme(prev||'light'); // restore visible state 271 try{ savedLS==null ? localStorage.removeItem('theme') : localStorage.setItem('theme',savedLS); }catch(e){} // don't clobber the user's real pref 272}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.