PageSourceSearch

https://epq-calculator.netlify.app/suite.js

js epq-calculator.netlify.app collected 2026-10-03 10:42:21 UTC 16,187 bytes, 272 lines download raw bytes

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.