From dc915c49cb13e9aea6ec97ad5dab09ba4d52411a Mon Sep 17 00:00:00 2001 From: Thomas Stromberg Date: Mon, 17 Nov 2025 21:23:32 -0500 Subject: [PATCH 1/3] retry on timeouts --- internal/server/static/index.html | 78 +++++++++++++++++++++++++++++-- 1 file changed, 75 insertions(+), 3 deletions(-) diff --git a/internal/server/static/index.html b/internal/server/static/index.html index d0e4303..e7862dd 100644 --- a/internal/server/static/index.html +++ b/internal/server/static/index.html @@ -2171,11 +2171,74 @@

Why calculate PR costs?

} }); - async function handleStreamingRequest(endpoint, request, resultDiv) { + async function handleStreamingRequest(endpoint, request, resultDiv, maxRetries = 8) { + // Wrap streaming with automatic retry logic using exponential backoff with jitter + // Max retries = 8 allows for backoff up to 120s: 1s, 2s, 4s, 8s, 16s, 32s, 64s, 120s + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + await attemptStreamingRequest(endpoint, request, resultDiv, attempt, maxRetries); + if (attempt > 1) { + console.log(`Stream request succeeded after ${attempt} attempts`); + } + return; // Success, exit + } catch (error) { + // Check if error is retryable (network/timeout errors) + const isRetryable = error.message.includes('Failed to fetch') || + error.message.includes('network') || + error.message.includes('timeout') || + error.message.includes('aborted') || + error.name === 'TypeError' || + error.name === 'AbortError'; + + if (isRetryable && attempt < maxRetries) { + // Exponential backoff with jitter: 1s, 2s, 4s, 8s, 16s, 32s, 64s, up to 120s + const baseDelay = Math.min(1000 * Math.pow(2, attempt - 1), 120000); + // Add jitter: random value between 0% and 25% of base delay + const jitter = Math.random() * 0.25 * baseDelay; + const delay = Math.floor(baseDelay + jitter); + + console.log(`Stream connection lost (attempt ${attempt}/${maxRetries}): ${error.message}`); + console.log(`Retrying in ${delay}ms with exponential backoff + jitter`); + + // Show retry message to user + const submitBtn = document.querySelector('button[type="submit"]'); + submitBtn.textContent = `Connection lost, retrying in ${Math.ceil(delay/1000)}s...`; + + await new Promise(resolve => setTimeout(resolve, delay)); + continue; + } + + // Non-retryable error or max retries exceeded + console.error(`Stream request failed permanently: ${error.message}`); + throw error; + } + } + } + + async function attemptStreamingRequest(endpoint, request, resultDiv, attempt, maxRetries) { // EventSource doesn't support POST, so we need a different approach // We'll use fetch to initiate, but handle it as a proper SSE stream return new Promise((resolve, reject) => { let progressContainer; + let lastActivityTime = Date.now(); + let timeoutId; + let reader; // Declare reader in outer scope to prevent race condition + + // Set up activity timeout (10 seconds of no data = connection lost) + // Server sends updates every ~5s, so 10s allows for network latency + const resetTimeout = () => { + if (timeoutId) clearTimeout(timeoutId); + lastActivityTime = Date.now(); + timeoutId = setTimeout(() => { + const elapsed = Date.now() - lastActivityTime; + if (elapsed >= 10000) { + if (reader) { + reader.cancel().catch(() => {}); // Ignore cancel errors + } + reject(new Error('Stream timeout: no data received for 10 seconds')); + } + }, 10000); + }; // Make the POST request with fetch fetch(endpoint, { @@ -2186,23 +2249,29 @@

Why calculate PR costs?

body: JSON.stringify(request) }).then(response => { if (!response.ok) { + if (timeoutId) clearTimeout(timeoutId); return response.text().then(error => { throw new Error(error || `HTTP ${response.status}`); }); } // Use the proper streaming API with ReadableStream - const reader = response.body.getReader(); + reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; + resetTimeout(); // Start timeout monitoring + function read() { reader.read().then(({done, value}) => { if (done) { + if (timeoutId) clearTimeout(timeoutId); resolve(); return; } + resetTimeout(); // Reset timeout on data received + buffer += decoder.decode(value, {stream: true}); const lines = buffer.split('\n'); buffer = lines.pop() || ''; @@ -2215,7 +2284,8 @@

Why calculate PR costs?

if (data.type === 'error' && !data.pr) { // Global error - reader.cancel(); + if (timeoutId) clearTimeout(timeoutId); + reader.cancel().catch(() => {}); // Ignore cancel errors reject(new Error(data.error)); return; } @@ -2365,6 +2435,7 @@

Why calculate PR costs?

// Continue reading read(); }).catch(error => { + if (timeoutId) clearTimeout(timeoutId); reject(error); }); } @@ -2372,6 +2443,7 @@

Why calculate PR costs?

// Start reading read(); }).catch(error => { + if (timeoutId) clearTimeout(timeoutId); reject(error); }); }); From b9c303ee5c907480d34507b78787e02b4fbb74ed Mon Sep 17 00:00:00 2001 From: Thomas Stromberg Date: Mon, 17 Nov 2025 21:29:00 -0500 Subject: [PATCH 2/3] improvements to button state --- internal/server/static/index.html | 43 +++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/internal/server/static/index.html b/internal/server/static/index.html index e7862dd..e943320 100644 --- a/internal/server/static/index.html +++ b/internal/server/static/index.html @@ -2021,6 +2021,35 @@

Why calculate PR costs?

return output; } + // Track form state to grey out button after successful calculation + let formModifiedSinceLastCalculation = true; + let lastCalculationSuccessful = false; + + function markFormAsModified() { + formModifiedSinceLastCalculation = true; + const submitBtn = document.getElementById('submitBtn'); + if (lastCalculationSuccessful) { + submitBtn.disabled = false; + submitBtn.textContent = 'Calculate Cost'; + } + } + + function markCalculationComplete(success) { + lastCalculationSuccessful = success; + formModifiedSinceLastCalculation = false; + const submitBtn = document.getElementById('submitBtn'); + if (success) { + submitBtn.disabled = true; + submitBtn.textContent = 'Costs calculated, scroll down...'; + } + } + + // Add change listeners to all form inputs + document.querySelectorAll('#costForm input, #costForm select').forEach(input => { + input.addEventListener('change', markFormAsModified); + input.addEventListener('input', markFormAsModified); + }); + document.getElementById('costForm').addEventListener('submit', async (e) => { e.preventDefault(); @@ -2160,14 +2189,23 @@

Why calculate PR costs?

html += ''; resultDiv.innerHTML = html; + markCalculationComplete(true); } } catch (error) { resultDiv.innerHTML = `
Error: ${error.message}
`; + markCalculationComplete(false); } finally { submitBtn.classList.remove('calculating'); - submitBtn.disabled = false; - submitBtn.textContent = 'Calculate Cost'; + // Only re-enable button if form was modified or calculation failed + if (!lastCalculationSuccessful || formModifiedSinceLastCalculation) { + submitBtn.disabled = false; + submitBtn.textContent = 'Calculate Cost'; + } else { + // Calculation succeeded and form unchanged - keep button disabled + submitBtn.disabled = true; + submitBtn.textContent = 'Costs calculated, scroll down...'; + } } }); @@ -2404,6 +2442,7 @@

Why calculate PR costs?

html += ''; resultDiv.innerHTML = html; + markCalculationComplete(true); resolve(); return; } From f709b08532675e5be642a4fa3a08b3807564759e Mon Sep 17 00:00:00 2001 From: Thomas Stromberg Date: Mon, 17 Nov 2025 21:35:13 -0500 Subject: [PATCH 3/3] improve grades --- internal/server/static/index.html | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/internal/server/static/index.html b/internal/server/static/index.html index e943320..ce8ca69 100644 --- a/internal/server/static/index.html +++ b/internal/server/static/index.html @@ -1396,23 +1396,17 @@

Why calculate PR costs?

function mergeVelocityGrade(avgOpenHours) { if (avgOpenHours <= 4) { - return { grade: 'A+', message: 'Impeccable' }; - } else if (avgOpenHours <= 8) { - return { grade: 'A', message: 'Excellent' }; - } else if (avgOpenHours <= 12) { - return { grade: 'A-', message: 'Nearly excellent' }; - } else if (avgOpenHours <= 18) { - return { grade: 'B+', message: 'Acceptable+' }; - } else if (avgOpenHours <= 24) { - return { grade: 'B', message: 'Acceptable' }; - } else if (avgOpenHours <= 36) { - return { grade: 'B-', message: 'Nearly acceptable' }; - } else if (avgOpenHours <= 100) { - return { grade: 'C', message: 'Average' }; - } else if (avgOpenHours <= 120) { - return { grade: 'D', message: 'Not good my friend.' }; + return { grade: 'A+', message: 'World-class velocity' }; + } else if (avgOpenHours <= 24) { // 1 day + return { grade: 'A', message: 'High-performing team' }; + } else if (avgOpenHours <= 84) { // 3.5 days + return { grade: 'B', message: 'Room for improvement' }; + } else if (avgOpenHours <= 132) { // 5.5 days + return { grade: 'C', message: 'Significant delays present' }; + } else if (avgOpenHours <= 192) { // 8 days + return { grade: 'D', message: 'Needs attention' }; } else { - return { grade: 'F', message: 'Failing' }; + return { grade: 'F', message: 'Critical bottleneck' }; } } @@ -1427,6 +1421,7 @@

Why calculate PR costs?

html += `${efficiencyPct.toFixed(1)}%`; html += ''; html += `
${message}
`; + html += '
Expected costs minus delay costs
'; html += ''; // Close efficiency-box // Merge Velocity box @@ -1449,7 +1444,7 @@

Why calculate PR costs?

html += `
${annualWasteFormatted}
`; const annualCostPerHead = salary * benefitsMultiplier; const headcount = annualWasteCost / annualCostPerHead; - html += `
${headcount.toFixed(1)} headcount
`; + html += `
Equal to ${headcount.toFixed(1)} engineers
`; html += ''; // Close efficiency-box }