@@ -1396,23 +1396,17 @@ <h3>Why calculate PR costs?</h3>
13961396
13971397 function mergeVelocityGrade ( avgOpenHours ) {
13981398 if ( avgOpenHours <= 4 ) {
1399- return { grade : 'A+' , message : 'Impeccable' } ;
1400- } else if ( avgOpenHours <= 8 ) {
1401- return { grade : 'A' , message : 'Excellent' } ;
1402- } else if ( avgOpenHours <= 12 ) {
1403- return { grade : 'A-' , message : 'Nearly excellent' } ;
1404- } else if ( avgOpenHours <= 18 ) {
1405- return { grade : 'B+' , message : 'Acceptable+' } ;
1406- } else if ( avgOpenHours <= 24 ) {
1407- return { grade : 'B' , message : 'Acceptable' } ;
1408- } else if ( avgOpenHours <= 36 ) {
1409- return { grade : 'B-' , message : 'Nearly acceptable' } ;
1410- } else if ( avgOpenHours <= 100 ) {
1411- return { grade : 'C' , message : 'Average' } ;
1412- } else if ( avgOpenHours <= 120 ) {
1413- return { grade : 'D' , message : 'Not good my friend.' } ;
1399+ return { grade : 'A+' , message : 'World-class velocity' } ;
1400+ } else if ( avgOpenHours <= 24 ) { // 1 day
1401+ return { grade : 'A' , message : 'High-performing team' } ;
1402+ } else if ( avgOpenHours <= 84 ) { // 3.5 days
1403+ return { grade : 'B' , message : 'Room for improvement' } ;
1404+ } else if ( avgOpenHours <= 132 ) { // 5.5 days
1405+ return { grade : 'C' , message : 'Significant delays present' } ;
1406+ } else if ( avgOpenHours <= 192 ) { // 8 days
1407+ return { grade : 'D' , message : 'Needs attention' } ;
14141408 } else {
1415- return { grade : 'F' , message : 'Failing ' } ;
1409+ return { grade : 'F' , message : 'Critical bottleneck ' } ;
14161410 }
14171411 }
14181412
@@ -1427,6 +1421,7 @@ <h3>Why calculate PR costs?</h3>
14271421 html += `<span style="font-size: 28px; font-weight: 700; color: #1d1d1f;">${ efficiencyPct . toFixed ( 1 ) } %</span>` ;
14281422 html += '</div>' ;
14291423 html += `<div class="efficiency-message">${ message } </div>` ;
1424+ html += '<div style="font-size: 11px; color: #86868b; margin-top: 4px;">Expected costs minus delay costs</div>' ;
14301425 html += '</div>' ; // Close efficiency-box
14311426
14321427 // Merge Velocity box
@@ -1449,7 +1444,7 @@ <h3>Why calculate PR costs?</h3>
14491444 html += `<div style="font-size: 28px; font-weight: 700; color: #1d1d1f; margin-bottom: 4px;">${ annualWasteFormatted } </div>` ;
14501445 const annualCostPerHead = salary * benefitsMultiplier ;
14511446 const headcount = annualWasteCost / annualCostPerHead ;
1452- html += `<div class="efficiency-message">${ headcount . toFixed ( 1 ) } headcount </div>` ;
1447+ html += `<div class="efficiency-message">Equal to ${ headcount . toFixed ( 1 ) } engineers </div>` ;
14531448 html += '</div>' ; // Close efficiency-box
14541449 }
14551450
@@ -2021,6 +2016,35 @@ <h3>Why calculate PR costs?</h3>
20212016 return output ;
20222017 }
20232018
2019+ // Track form state to grey out button after successful calculation
2020+ let formModifiedSinceLastCalculation = true ;
2021+ let lastCalculationSuccessful = false ;
2022+
2023+ function markFormAsModified ( ) {
2024+ formModifiedSinceLastCalculation = true ;
2025+ const submitBtn = document . getElementById ( 'submitBtn' ) ;
2026+ if ( lastCalculationSuccessful ) {
2027+ submitBtn . disabled = false ;
2028+ submitBtn . textContent = 'Calculate Cost' ;
2029+ }
2030+ }
2031+
2032+ function markCalculationComplete ( success ) {
2033+ lastCalculationSuccessful = success ;
2034+ formModifiedSinceLastCalculation = false ;
2035+ const submitBtn = document . getElementById ( 'submitBtn' ) ;
2036+ if ( success ) {
2037+ submitBtn . disabled = true ;
2038+ submitBtn . textContent = 'Costs calculated, scroll down...' ;
2039+ }
2040+ }
2041+
2042+ // Add change listeners to all form inputs
2043+ document . querySelectorAll ( '#costForm input, #costForm select' ) . forEach ( input => {
2044+ input . addEventListener ( 'change' , markFormAsModified ) ;
2045+ input . addEventListener ( 'input' , markFormAsModified ) ;
2046+ } ) ;
2047+
20242048 document . getElementById ( 'costForm' ) . addEventListener ( 'submit' , async ( e ) => {
20252049 e . preventDefault ( ) ;
20262050
@@ -2160,22 +2184,94 @@ <h3>Why calculate PR costs?</h3>
21602184 html += '</div>' ;
21612185
21622186 resultDiv . innerHTML = html ;
2187+ markCalculationComplete ( true ) ;
21632188 }
21642189
21652190 } catch ( error ) {
21662191 resultDiv . innerHTML = `<div class="error">Error: ${ error . message } </div>` ;
2192+ markCalculationComplete ( false ) ;
21672193 } finally {
21682194 submitBtn . classList . remove ( 'calculating' ) ;
2169- submitBtn . disabled = false ;
2170- submitBtn . textContent = 'Calculate Cost' ;
2195+ // Only re-enable button if form was modified or calculation failed
2196+ if ( ! lastCalculationSuccessful || formModifiedSinceLastCalculation ) {
2197+ submitBtn . disabled = false ;
2198+ submitBtn . textContent = 'Calculate Cost' ;
2199+ } else {
2200+ // Calculation succeeded and form unchanged - keep button disabled
2201+ submitBtn . disabled = true ;
2202+ submitBtn . textContent = 'Costs calculated, scroll down...' ;
2203+ }
21712204 }
21722205 } ) ;
21732206
2174- async function handleStreamingRequest ( endpoint , request , resultDiv ) {
2207+ async function handleStreamingRequest ( endpoint , request , resultDiv , maxRetries = 8 ) {
2208+ // Wrap streaming with automatic retry logic using exponential backoff with jitter
2209+ // Max retries = 8 allows for backoff up to 120s: 1s, 2s, 4s, 8s, 16s, 32s, 64s, 120s
2210+ for ( let attempt = 1 ; attempt <= maxRetries ; attempt ++ ) {
2211+ try {
2212+ await attemptStreamingRequest ( endpoint , request , resultDiv , attempt , maxRetries ) ;
2213+ if ( attempt > 1 ) {
2214+ console . log ( `Stream request succeeded after ${ attempt } attempts` ) ;
2215+ }
2216+ return ; // Success, exit
2217+ } catch ( error ) {
2218+ // Check if error is retryable (network/timeout errors)
2219+ const isRetryable = error . message . includes ( 'Failed to fetch' ) ||
2220+ error . message . includes ( 'network' ) ||
2221+ error . message . includes ( 'timeout' ) ||
2222+ error . message . includes ( 'aborted' ) ||
2223+ error . name === 'TypeError' ||
2224+ error . name === 'AbortError' ;
2225+
2226+ if ( isRetryable && attempt < maxRetries ) {
2227+ // Exponential backoff with jitter: 1s, 2s, 4s, 8s, 16s, 32s, 64s, up to 120s
2228+ const baseDelay = Math . min ( 1000 * Math . pow ( 2 , attempt - 1 ) , 120000 ) ;
2229+ // Add jitter: random value between 0% and 25% of base delay
2230+ const jitter = Math . random ( ) * 0.25 * baseDelay ;
2231+ const delay = Math . floor ( baseDelay + jitter ) ;
2232+
2233+ console . log ( `Stream connection lost (attempt ${ attempt } /${ maxRetries } ): ${ error . message } ` ) ;
2234+ console . log ( `Retrying in ${ delay } ms with exponential backoff + jitter` ) ;
2235+
2236+ // Show retry message to user
2237+ const submitBtn = document . querySelector ( 'button[type="submit"]' ) ;
2238+ submitBtn . textContent = `Connection lost, retrying in ${ Math . ceil ( delay / 1000 ) } s...` ;
2239+
2240+ await new Promise ( resolve => setTimeout ( resolve , delay ) ) ;
2241+ continue ;
2242+ }
2243+
2244+ // Non-retryable error or max retries exceeded
2245+ console . error ( `Stream request failed permanently: ${ error . message } ` ) ;
2246+ throw error ;
2247+ }
2248+ }
2249+ }
2250+
2251+ async function attemptStreamingRequest ( endpoint , request , resultDiv , attempt , maxRetries ) {
21752252 // EventSource doesn't support POST, so we need a different approach
21762253 // We'll use fetch to initiate, but handle it as a proper SSE stream
21772254 return new Promise ( ( resolve , reject ) => {
21782255 let progressContainer ;
2256+ let lastActivityTime = Date . now ( ) ;
2257+ let timeoutId ;
2258+ let reader ; // Declare reader in outer scope to prevent race condition
2259+
2260+ // Set up activity timeout (10 seconds of no data = connection lost)
2261+ // Server sends updates every ~5s, so 10s allows for network latency
2262+ const resetTimeout = ( ) => {
2263+ if ( timeoutId ) clearTimeout ( timeoutId ) ;
2264+ lastActivityTime = Date . now ( ) ;
2265+ timeoutId = setTimeout ( ( ) => {
2266+ const elapsed = Date . now ( ) - lastActivityTime ;
2267+ if ( elapsed >= 10000 ) {
2268+ if ( reader ) {
2269+ reader . cancel ( ) . catch ( ( ) => { } ) ; // Ignore cancel errors
2270+ }
2271+ reject ( new Error ( 'Stream timeout: no data received for 10 seconds' ) ) ;
2272+ }
2273+ } , 10000 ) ;
2274+ } ;
21792275
21802276 // Make the POST request with fetch
21812277 fetch ( endpoint , {
@@ -2186,23 +2282,29 @@ <h3>Why calculate PR costs?</h3>
21862282 body : JSON . stringify ( request )
21872283 } ) . then ( response => {
21882284 if ( ! response . ok ) {
2285+ if ( timeoutId ) clearTimeout ( timeoutId ) ;
21892286 return response . text ( ) . then ( error => {
21902287 throw new Error ( error || `HTTP ${ response . status } ` ) ;
21912288 } ) ;
21922289 }
21932290
21942291 // Use the proper streaming API with ReadableStream
2195- const reader = response . body . getReader ( ) ;
2292+ reader = response . body . getReader ( ) ;
21962293 const decoder = new TextDecoder ( ) ;
21972294 let buffer = '' ;
21982295
2296+ resetTimeout ( ) ; // Start timeout monitoring
2297+
21992298 function read ( ) {
22002299 reader . read ( ) . then ( ( { done, value} ) => {
22012300 if ( done ) {
2301+ if ( timeoutId ) clearTimeout ( timeoutId ) ;
22022302 resolve ( ) ;
22032303 return ;
22042304 }
22052305
2306+ resetTimeout ( ) ; // Reset timeout on data received
2307+
22062308 buffer += decoder . decode ( value , { stream : true } ) ;
22072309 const lines = buffer . split ( '\n' ) ;
22082310 buffer = lines . pop ( ) || '' ;
@@ -2215,7 +2317,8 @@ <h3>Why calculate PR costs?</h3>
22152317
22162318 if ( data . type === 'error' && ! data . pr ) {
22172319 // Global error
2218- reader . cancel ( ) ;
2320+ if ( timeoutId ) clearTimeout ( timeoutId ) ;
2321+ reader . cancel ( ) . catch ( ( ) => { } ) ; // Ignore cancel errors
22192322 reject ( new Error ( data . error ) ) ;
22202323 return ;
22212324 }
@@ -2334,6 +2437,7 @@ <h3>Why calculate PR costs?</h3>
23342437 html += '</div>' ;
23352438
23362439 resultDiv . innerHTML = html ;
2440+ markCalculationComplete ( true ) ;
23372441 resolve ( ) ;
23382442 return ;
23392443 }
@@ -2365,13 +2469,15 @@ <h3>Why calculate PR costs?</h3>
23652469 // Continue reading
23662470 read ( ) ;
23672471 } ) . catch ( error => {
2472+ if ( timeoutId ) clearTimeout ( timeoutId ) ;
23682473 reject ( error ) ;
23692474 } ) ;
23702475 }
23712476
23722477 // Start reading
23732478 read ( ) ;
23742479 } ) . catch ( error => {
2480+ if ( timeoutId ) clearTimeout ( timeoutId ) ;
23752481 reject ( error ) ;
23762482 } ) ;
23772483 } ) ;
0 commit comments