Skip to content

Commit 094ea72

Browse files
authored
Merge pull request #20 from tstromberg/main
Improve web UI (timeouts, button states, wording)
2 parents 9d52a47 + f709b08 commit 094ea72

File tree

1 file changed

+128
-22
lines changed

1 file changed

+128
-22
lines changed

internal/server/static/index.html

Lines changed: 128 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)