welcome, sister

Log the Time, Let the Invoice Write Itself

Track real hours against real jobs — when it's done, your actual time becomes the quote. No more guessing, no more eating the difference.

These are just starting defaults — every job can override its own rate and margin.

Your jobs

Your track record by job type

New job

Job

00:00:00
'; var w = window.open('', '_blank'); w.document.write(html); w.document.close(); w.print();renderDetailBody(); renderAll(); });// ---------- Global render ---------- function renderAll(){ els.defaultRate.value = state.settings.defaultRate; els.defaultMargin.value = state.settings.defaultMargin; renderStats(); renderInsight(); renderProjectList(); renderJobTypeTable(); refreshJobTypeList(); }// ---------- Export / Backup / Restore ---------- function download(filename, content, mime){ var blob = new Blob([content], { type: mime }); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } els.exportCsv.addEventListener('click', function(){ var rows = [['Job','Client','Job type','Status','Estimated hours','Actual hours','Rate','Total value','Invoice number','Invoice date']]; state.projects.forEach(function(p){ var cost = projectCost(p); rows.push([p.name, p.client||'', p.jobType||'', p.status, p.estHours||'', cost.hours.toFixed(2), effRate(p), cost.total.toFixed(2), p.invoiceNumber||'', p.invoiceDate||'']); }); var csv = rows.map(function(r){ return r.map(function(c){ return '"' + String(c).replace(/"/g,'""') + '"'; }).join(','); }).join('\n'); download('time-to-quote-' + todayISO() + '.csv', csv, 'text/csv'); }); els.backup.addEventListener('click', function(){ download('time-to-quote-backup-' + todayISO() + '.json', JSON.stringify(state, null, 2), 'application/json'); }); els.restoreTrigger.addEventListener('click', function(){ els.restoreFile.click(); }); els.restoreFile.addEventListener('change', function(e){ var file = e.target.files[0]; if(!file) return; var reader = new FileReader(); reader.onload = function(evt){ try{ var data = JSON.parse(evt.target.result); if(!data || typeof data !== 'object') throw new Error('bad format'); if(!confirm('This will replace your current jobs, time entries and settings with the backup. Continue?')) return; state.settings = Object.assign({ defaultRate: 450, defaultMargin: 0, nextInvoiceNumber: 1 }, data.settings || {}); state.projects = data.projects || []; state.entries = data.entries || []; save(); renderAll(); }catch(err){ alert('That file does not look like a valid backup.'); } }; reader.readAsText(file); els.restoreFile.value = ''; });// ---------- Init ---------- load(); loadTimer(); if(timer.running) tickHandle = setInterval(tick, 1000); renderAll(); })();