Add lightweight Tauri shell, vendor offline libs, fix bugs and race conditions

- Add tauri/ subfolder: a Tauri-based desktop shell that loads the same
  index.html as the Electron app, producing a ~2MB installer (vs ~71MB for
  Electron) by using the OS WebView2 instead of bundled Chromium. Includes
  sync-frontend.js to keep tauri/dist/ in sync with the canonical index.html
  and vendor/ at build time (no UI duplication/drift).
- Vendor lucide, jsPDF, and jspdf-autotable locally under vendor/ instead of
  loading from CDNs, so the app works fully offline in both shells.
- Fix server.js path traversal vulnerability (sanitize/contain request paths).
- Fix exportPDF() using stale/edited piece data that could produce
  Infinity/NaN in generated PDFs; add a recalculate guard.
- Fix race conditions: overlapping copy/save-defaults button revert timers,
  and FileReader callbacks for logo/CSV import that could apply out of order.
- Minor cleanups: explicit array index parsing, hasResult() helper, consistent
  event binding for NMFC search results, cached DOM lookups.
- Document the dual-shell architecture and vendoring conventions in CLAUDE.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Devon Bontrager
2026-06-08 11:19:48 -04:00
parent 3c3ef02f20
commit e5ed1d3c9f
24 changed files with 25160 additions and 12 deletions
+55 -11
View File
@@ -3,9 +3,10 @@
<head>
<meta charset="UTF-8">
<title>Freight Calculator Pro</title>
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
<!-- Vendored locally (vendor/) so the app works fully offline — see CLAUDE.md -->
<script src="vendor/lucide.js"></script>
<script src="vendor/jspdf.umd.min.js"></script>
<script src="vendor/jspdf.plugin.autotable.min.js"></script>
<style>
:root {
--primary: #2563eb; --primary-hover: #1d4ed8; --bg-color: #f1f5f9;
@@ -472,6 +473,13 @@ let pendingBranding = null;
// Working pieces array (ephemeral, not persisted)
let pieces = [{ L:'', W:'', H:'', Weight:'' }];
// Race-condition guards: track in-flight timers/readers so rapid repeat
// actions don't let a stale callback clobber a newer one.
let copyRevertTimer = null;
let saveDefaultsRevertTimer = null;
let logoReadToken = 0;
let csvImportToken = 0;
function saveState() { localStorage.setItem('freightCalcState', JSON.stringify(appState)); }
// ─── Init ──────────────────────────────────────────────────────────────────────
@@ -549,8 +557,10 @@ document.getElementById('setting-color').oninput = e => {
};
document.getElementById('setting-logo').onchange = e => {
if (!e.target.files[0]) return;
const myToken = ++logoReadToken;
const reader = new FileReader();
reader.onload = evt => {
if (myToken !== logoReadToken) return; // a newer file selection superseded this read
appState.branding.logo = evt.target.result;
const preview = document.getElementById('logo-preview');
preview.src = evt.target.result; preview.style.display = 'block';
@@ -574,8 +584,12 @@ document.getElementById('save-defaults-btn').onclick = () => {
};
saveState();
const btn = document.getElementById('save-defaults-btn');
if (saveDefaultsRevertTimer) clearTimeout(saveDefaultsRevertTimer);
btn.innerHTML = '<i data-lucide="check"></i> Saved!'; lucide.createIcons();
setTimeout(() => { btn.innerHTML = '<i data-lucide="save"></i> Save Default'; lucide.createIcons(); }, 1800);
saveDefaultsRevertTimer = setTimeout(() => {
btn.innerHTML = '<i data-lucide="save"></i> Save Default'; lucide.createIcons();
saveDefaultsRevertTimer = null;
}, 1800);
};
document.getElementById('apply-defaults-btn').onclick = () => {
const d = appState.defaults;
@@ -697,14 +711,18 @@ function exportHistoryCSV() {
a.href = URL.createObjectURL(blob);
a.download = `freight-history-${new Date().toISOString().slice(0,10)}.csv`;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 100);
// Defer revocation generously so slower systems/rapid consecutive exports
// have time to actually start the download before the blob URL dies.
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
// Import history from CSV
function importHistoryCSV(e) {
const file = e.target.files[0]; if (!file) return;
const myToken = ++csvImportToken;
const reader = new FileReader();
reader.onload = evt => {
if (myToken !== csvImportToken) return; // a newer import selection superseded this read
const lines = evt.target.result.trim().split('\n').slice(1); // skip header
const imported = [];
lines.forEach(line => {
@@ -782,7 +800,7 @@ function renderPieces() {
});
// Bind input events
container.querySelectorAll('input[type="number"]').forEach(el => {
el.addEventListener('input', e => { pieces[e.target.dataset.idx][e.target.dataset.field] = e.target.value; });
el.addEventListener('input', e => { pieces[parseInt(e.target.dataset.idx, 10)][e.target.dataset.field] = e.target.value; });
el.addEventListener('keydown', e => { if (e.key === 'Enter') calculateFreightClass(); });
});
// Bind delete buttons
@@ -801,7 +819,10 @@ function addPiece() {
// Focus first input of new row
const rows = document.querySelectorAll('.piece-row');
const lastRow = rows[rows.length-1];
if (lastRow) lastRow.querySelector('input') && lastRow.querySelector('input').focus();
if (lastRow) {
const firstInput = lastRow.querySelector('input');
if (firstInput) firstInput.focus();
}
}
// ─── Validation & Clear ───────────────────────────────────────────────────────
@@ -884,9 +905,14 @@ function calculateFreightClass() {
lucide.createIcons();
}
// Returns true once a calculation has produced a result (i.e. not the placeholder '--')
function hasResult() {
return document.getElementById('class-result').textContent !== '--';
}
// ─── Copy to clipboard ────────────────────────────────────────────────────────
function copyResult() {
if (document.getElementById('class-result').textContent === '--') return;
if (!hasResult()) return;
const cls = document.getElementById('class-result').textContent;
const density = document.getElementById('density-result').textContent;
const du = appState.units.dim, wu = appState.units.wt;
@@ -894,19 +920,34 @@ function copyResult() {
const text = `Freight Class: ${cls}\nDensity: ${density} lb/ft³\n${pLines}`;
navigator.clipboard.writeText(text).then(() => {
const btn = document.getElementById('copy-btn');
if (copyRevertTimer) clearTimeout(copyRevertTimer);
btn.classList.add('copied');
btn.innerHTML = '<i data-lucide="check"></i> Copied!'; lucide.createIcons();
setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = '<i data-lucide="copy"></i> Copy'; lucide.createIcons(); }, 2000);
copyRevertTimer = setTimeout(() => {
btn.classList.remove('copied');
btn.innerHTML = '<i data-lucide="copy"></i> Copy'; lucide.createIcons();
copyRevertTimer = null;
}, 2000);
});
}
// ─── PDF Export ───────────────────────────────────────────────────────────────
function exportPDF() {
if (document.getElementById('class-result').textContent === '--') {
if (!hasResult()) {
alert('Please calculate a freight class before exporting a PDF.');
return;
}
if (typeof window.jspdf === 'undefined') { alert('PDF library not loaded. Please check your internet connection.'); return; }
// Guard against stale/edited piece data producing Infinity/NaN in the PDF
// (e.g. user calculates, then clears a dimension before exporting without recalculating).
const piecesValidForExport = pieces.every(p => {
const L = parseFloat(p.L), Wd = parseFloat(p.W), H = parseFloat(p.H), Wt = parseFloat(p.Weight);
return L > 0 && Wd > 0 && H > 0 && Wt > 0;
});
if (!piecesValidForExport) {
alert('Piece values have changed since the last calculation. Please recalculate before exporting a PDF.');
return;
}
const { jsPDF } = window.jspdf;
const doc = new jsPDF({ unit:'mm', format:'letter' });
const W = doc.internal.pageSize.getWidth();
@@ -1031,7 +1072,7 @@ function nmfcSearch(query) {
}
let html = '<table><thead><tr><th>NMFC Code</th><th>Description</th><th>Std Class</th></tr></thead><tbody>';
results.forEach(n => {
html += `<tr onclick="nmfcSelect(${n.cls})">
html += `<tr data-cls="${n.cls}">
<td style="font-family:monospace;font-size:0.85rem">${n.code}</td>
<td>${n.desc}</td>
<td><span class="nmfc-class-badge">${n.cls}</span></td>
@@ -1039,6 +1080,9 @@ function nmfcSearch(query) {
});
html += '</tbody></table>';
container.innerHTML = html;
container.querySelectorAll('tbody tr').forEach(row => {
row.addEventListener('click', () => nmfcSelect(parseFloat(row.dataset.cls)));
});
}
function nmfcSelect(cls) {
// Highlight the matching row in the density reference table