Created
March 7, 2026 05:08
-
-
Save EncodeTheCode/07650ed68e60391dc4c6b07293edff1e to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="sv"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> | |
| <title>Ekonomi Uträkning - Stable Price Editing & Hold Ramp</title> | |
| <style> | |
| :root{ | |
| --page-width:210mm; | |
| --page-height:297mm; | |
| --line-spacing:8mm; | |
| --left-margin:25mm; | |
| --left-line-width:2px; | |
| --line-color: rgba(6,95,70,0.18); | |
| --left-line-color: rgba(220,38,38,0.18); | |
| --paper-bg: #fffdf9; | |
| --ink: #05202b; | |
| --text-y-offset: 4px; | |
| } | |
| *{box-sizing:border-box} | |
| html,body{height:100%;margin:0;background:#f0f4f7;font-family:Segoe UI, Roboto, Inter, system-ui, -apple-system} | |
| .app{min-height:100%;display:flex;align-items:center;justify-content:center;padding:24px} | |
| .page{width:var(--page-width);min-height:var(--page-height);background:var(--paper-bg);position:relative;box-shadow:0 24px 48px rgba(2,6,23,0.28);border-radius:6px;overflow:hidden;padding:18mm 18mm 14mm;display:block} | |
| .header-bar{display:flex;align-items:center;justify-content:space-between;margin-bottom:8mm} | |
| .title{display:flex;align-items:center;gap:12px;color:var(--ink)} | |
| .title .app-icon{font-size:22px} | |
| .title h1{font-size:18px;margin:0} | |
| .controls{display:flex;gap:8px;align-items:center} | |
| .select, .button, .file-input-label{font-size:13px;padding:8px 10px;border-radius:6px;border:1px solid rgba(2,6,23,0.06);background:rgba(255,255,255,0.9);cursor:pointer} | |
| .select{appearance:none} | |
| .button{background:linear-gradient(180deg,#0ea5a4,#0a8e85);color:#fff;border:none;box-shadow:0 8px 22px rgba(10,130,120,0.12)} | |
| .button.secondary{background:#fff;color:var(--ink);border:1px solid rgba(2,6,23,0.06)} | |
| .file-input-label{display:inline-flex;align-items:center;gap:8px} | |
| .notebook{position:relative;padding:12px 14px 18px 18px;background-image:linear-gradient(to right, var(--left-line-color) var(--left-line-width), transparent var(--left-line-width)),linear-gradient(to bottom, var(--line-color) 1px, transparent 1px);background-size: var(--left-margin) 100%, 100% var(--line-spacing);background-position: left top, left top;background-repeat: no-repeat, repeat-y;border-radius:6px;margin-bottom:8mm} | |
| .notebook::before{content:"";position:absolute;left:6px;top:12mm;bottom:12mm;width:20px; pointer-events:none;background-image: radial-gradient(circle at 50% 50%, rgba(2,6,23,0.12) 0 3px, transparent 4px);background-size: 20px 72mm; background-repeat: repeat-y;opacity:0.6} | |
| .table-wrap{width:100%;margin-top:6px} | |
| table{width:100%;border-collapse:collapse;background:transparent} | |
| thead th{color:var(--ink);opacity:0.95;text-align:left;padding:6px 6px;font-size:13px} | |
| tbody tr{height:var(--line-spacing);vertical-align:middle} | |
| tbody td{padding:0 6px;border:0;vertical-align:middle} | |
| .row-cell{display:flex;align-items:center;height:100%;overflow:visible} | |
| .input{position:relative;top:8px;width:100%;border:none;background:transparent;color:var(--ink);font-size:14px;padding:0 6px;height:calc(var(--line-spacing) - 2px);line-height:calc(var(--line-spacing) - 2px);overflow-y:visible} | |
| .input.number{max-width:180px;text-align:right;font-weight:600;font-size:15px;padding-right:10px} | |
| .input:focus{outline:none;box-shadow:0 6px 18px rgba(10,130,120,0.12)} | |
| .smallbtn{padding:6px 8px;border-radius:6px;border:1px solid rgba(2,6,23,0.06);background:rgba(255,255,255,0.9);cursor:pointer} | |
| .total{display:flex;justify-content:space-between;align-items:center;margin-top:18px;padding:12px;border-radius:8px;background:linear-gradient(90deg,#fff,#fbfdff);font-weight:700;color:var(--ink)} | |
| .footer{margin-top:14px;color:rgba(2,6,23,0.54);font-size:12px} | |
| @media print{body{background:white}.app{padding:0}.page{box-shadow:none;border-radius:0;padding:18mm}} | |
| @media(max-width:900px){.page{transform:scale(0.9);transform-origin:top center;width:190mm}} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="app"> | |
| <div class="page" role="document" aria-label="Notebook strict lines A4 page with stable price editing"> | |
| <div class="header-bar"> | |
| <div class="title"> | |
| <div class="app-icon">🧾</div> | |
| <div> | |
| <h1 id="titleText">Ekonomi – Faktura Lista</h1> | |
| <div style="font-size:11px;color:rgba(2,6,23,0.5)">Stable editing: click to place caret, type to append, hold arrows to ramp.</div> | |
| </div> | |
| </div> | |
| <div class="controls"> | |
| <select id="langSelect" class="select" aria-label="language select"> | |
| <option value="sv">Svenska (sv)</option> | |
| <option value="en">English (en)</option> | |
| </select> | |
| <button id="addBtn" class="button">➕ Lägg till faktura</button> | |
| <button id="exportBtn" class="button secondary">📤 Exportera</button> | |
| <label class="file-input-label" title="Importera JSON"> | |
| 📥 <span id="importBtnText">Importera</span> | |
| <input id="importFile" type="file" accept="application/json" style="display:none"> | |
| </label> | |
| <button id="resetBtn" class="button secondary">♻️ Reset</button> | |
| </div> | |
| </div> | |
| <div class="notebook"> | |
| <div class="table-wrap"> | |
| <table id="invoiceTable" aria-label="Invoice table"> | |
| <thead> | |
| <tr> | |
| <th style="width:44px"></th> | |
| <th id="thDesc">Beskrivning</th> | |
| <th id="thPrice" style="width:260px;text-align:right">Pris (SEK)</th> | |
| <th style="width:64px"></th> | |
| </tr> | |
| </thead> | |
| <tbody></tbody> | |
| </table> | |
| </div> | |
| <div class="total" aria-live="polite"> | |
| <div id="totalLabel">Total:</div> | |
| <div><span id="total">0.00</span> <span id="currency">SEK</span></div> | |
| </div> | |
| <div class="footer"> | |
| <small id="helpText">Click a field to edit — typing appends. Hold ↑/↓ to ramp the value (5s→10×,10s→50×).</small> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const translations = { | |
| sv: { title: 'Ekonomi – Faktura Lista', add: '➕ Lägg till faktura', export: '📤 Exportera', import: 'Importera', reset: '♻️ Reset', desc: 'Beskrivning', price: 'Pris (SEK)', total: 'Total:', help: 'Du kan exportera/importera hela listan som en JSON-fil.', invoiceEmoji: '🧾', remove: 'Ta bort' }, | |
| en: { title: 'Finance – Invoice List', add: '➕ Add invoice', export: '📤 Export', import: 'Import', reset: '♻️ Reset', desc: 'Description', price: 'Price (SEK)', total: 'Total:', help: 'You can export/import the whole list as a JSON file.', invoiceEmoji: '🧾', remove: 'Remove' } | |
| }; | |
| let data = []; | |
| let lang = localStorage.getItem('ekonomi_lang') || 'sv'; | |
| const saveKey = 'ekonomi_data_v4'; | |
| const tbody = document.querySelector('#invoiceTable tbody'); | |
| const totalEl = document.getElementById('total'); | |
| const totalLabelEl = document.getElementById('totalLabel'); | |
| const titleText = document.getElementById('titleText'); | |
| const thDesc = document.getElementById('thDesc'); | |
| const thPrice = document.getElementById('thPrice'); | |
| const helpText = document.getElementById('helpText'); | |
| const langSelect = document.getElementById('langSelect'); | |
| const addBtn = document.getElementById('addBtn'); | |
| const exportBtn = document.getElementById('exportBtn'); | |
| const importFile = document.getElementById('importFile'); | |
| const importBtnText = document.getElementById('importBtnText'); | |
| const resetBtn = document.getElementById('resetBtn'); | |
| function t(k){ return translations[lang][k] || k } | |
| function fmtNumber(n){ try{ const locale = lang === 'sv' ? 'sv-SE' : 'en-US'; return new Intl.NumberFormat(locale,{minimumFractionDigits:2,maximumFractionDigits:2}).format(Number(n || 0)); }catch(e){ return Number(n || 0).toFixed(2) } } | |
| function save(){ try{ localStorage.setItem(saveKey, JSON.stringify(data)); localStorage.setItem('ekonomi_lang', lang);}catch(e){} } | |
| function load(){ const raw = localStorage.getItem(saveKey); if(raw){ try{ data = JSON.parse(raw) }catch(e){ data = [] } } else { data = [] } } | |
| // Recalculate total using either normalized price or raw input if present | |
| function recalcTotal(){ | |
| let sum = 0; | |
| data.forEach(item=>{ | |
| if(item._raw !== undefined && item._raw !== ''){ | |
| // try parse | |
| const v = parseFloat(String(item._raw).replace(',','.')); | |
| sum += isNaN(v) ? 0 : v; | |
| } else { | |
| sum += Number(item.price || 0); | |
| } | |
| }); | |
| totalEl.textContent = fmtNumber(sum); | |
| } | |
| // Update price as user types (do NOT fully re-render). Keep raw input so caret remains stable. | |
| window.updatePrice = function(i, v){ | |
| // store raw text while typing | |
| data[i]._raw = v; | |
| // if empty, treat as 0 for totals | |
| recalcTotal(); | |
| save(); | |
| } | |
| // On blur, normalize value to 2 decimals and store as numeric price | |
| window.priceBlur = function(i, el){ | |
| const raw = el.value; | |
| const num = Math.round((parseFloat(String(raw).replace(',','.')) || 0) * 100) / 100; | |
| data[i].price = num; | |
| delete data[i]._raw; | |
| // update displayed formatted value | |
| el.value = num.toFixed(2); | |
| recalcTotal(); | |
| save(); | |
| } | |
| // Keyboard handling + hold-to-ramp logic — manipulates the input directly and updates model | |
| function handlePriceKey(e, index){ | |
| const el = e.target; | |
| // If user presses Escape -> restore previous value but keep focus and caret | |
| if(e.key === 'Escape'){ | |
| if(el.dataset.prev !== undefined) el.value = el.dataset.prev; | |
| // trigger input update | |
| window.updatePrice(index, el.value); | |
| e.preventDefault(); | |
| return; | |
| } | |
| if(e.key === 'ArrowUp' || e.key === 'ArrowDown'){ | |
| e.preventDefault(); | |
| const dir = e.key === 'ArrowUp' ? 1 : -1; | |
| // apply immediate single-step change | |
| const current = parseFloat(String(el.value).replace(',','.')) || 0; | |
| const immediate = current + dir; | |
| el.value = immediate.toFixed(2); | |
| window.updatePrice(index, el.value); | |
| // Start hold/ramp if this is the first keydown (not a repeated event) | |
| if(!el._holdInterval && !e.repeat){ | |
| el._holdStart = Date.now(); | |
| // tick every 300ms; multiplier increases with time held | |
| el._holdInterval = setInterval(()=>{ | |
| const elapsed = Date.now() - el._holdStart; | |
| const mult = elapsed >= 10000 ? 50 : (elapsed >= 5000 ? 10 : 1); | |
| const cur = parseFloat(String(el.value).replace(',','.')) || 0; | |
| const next = cur + dir * mult; | |
| el.value = next.toFixed(2); | |
| window.updatePrice(index, el.value); | |
| }, 300); | |
| } | |
| return; | |
| } | |
| // allow normal typing for digits; Enter/Tab behave normally (do not blur programmatically) | |
| } | |
| // Keep previous value on focusin for Escape undo; do NOT select the value so typing appends | |
| document.addEventListener('focusin', e => { | |
| const el = e.target; | |
| if(el && el.tagName === 'INPUT' && el.dataset && (el.dataset.row !== undefined)){ | |
| el.dataset.prev = el.value; | |
| // do not call el.select() — we intentionally preserve caret position so typing appends | |
| } | |
| }); | |
| // stop hold intervals when keyup or focusout | |
| document.addEventListener('keyup', e => { | |
| const el = document.activeElement; | |
| if(!el) return; | |
| if((e.key === 'ArrowUp' || e.key === 'ArrowDown') && el._holdInterval){ | |
| clearInterval(el._holdInterval); el._holdInterval = null; | |
| } | |
| }); | |
| document.addEventListener('focusout', e => { | |
| const el = e.target; | |
| if(el && el._holdInterval){ clearInterval(el._holdInterval); el._holdInterval = null; } | |
| }); | |
| // Rendering: create inputs with dataset attributes and attach events; do not re-render on every input change | |
| function render(){ | |
| tbody.innerHTML = ''; | |
| data.forEach((item, i)=>{ | |
| const tr = document.createElement('tr'); | |
| const descInput = `<div class="row-cell"><input class="input" data-row="${i}" data-col="desc" type="text" value="${escapeHtml(item.desc)}" oninput="updateDesc(${i}, this.value)" placeholder="${escapeHtml(t('desc'))}"></div>`; | |
| // show raw value if present (user typing), otherwise formatted price | |
| const shown = item._raw !== undefined ? item._raw : (typeof item.price !== 'undefined' ? Number(item.price).toFixed(2) : '0.00'); | |
| const priceCell = ` | |
| <div style="display:flex;align-items:center;justify-content:flex-end;gap:8px"> | |
| <input class="input number price-input" data-row="${i}" data-col="price" type="text" inputmode="decimal" value="${escapeHtml(shown)}" oninput="updatePrice(${i}, this.value)" onblur="priceBlur(${i}, this)" onkeydown="handlePriceKey(event, ${i})" aria-label="price"> | |
| </div> | |
| `; | |
| tr.innerHTML = ` | |
| <td style="width:48px;display:flex;align-items:center;justify-content:center">${t('invoiceEmoji')}</td> | |
| <td>${descInput}</td> | |
| <td style="text-align:right">${priceCell}</td> | |
| <td style="width:64px;text-align:center;vertical-align:middle"><button class="smallbtn" onclick="removeRow(${i})">${t('remove')}</button></td> | |
| `; | |
| tbody.appendChild(tr); | |
| }); | |
| recalcTotal(); | |
| save(); | |
| } | |
| function escapeHtml(s){ return String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>') } | |
| window.updateDesc = function(i, v){ data[i].desc = v; save(); } | |
| window.updatePrice = function(i, v){ | |
| // provided for inline handlers too — forward to function above | |
| // normalization and totals handled there | |
| data[i]._raw = v; | |
| recalcTotal(); | |
| save(); | |
| } | |
| window.removeRow = function(i){ data.splice(i,1); render(); } | |
| function addRow(){ data.push({desc:'', price:0}); render(); setTimeout(()=>{ const inputs = tbody.querySelectorAll('input[data-row]'); if(inputs.length){ inputs[inputs.length-1].focus(); } },60); } | |
| function exportData(){ const json = JSON.stringify(data.map(x=>({desc:x.desc||'',price:Number(x.price||0)})), null, 2); const blob = new Blob([json], {type:'application/json'}); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'ekonomi.json'; a.click(); } | |
| function importDataFromFile(file){ const reader = new FileReader(); reader.onload = (e)=>{ try{ const parsed = JSON.parse(e.target.result); if(Array.isArray(parsed)){ data = parsed.map(x=>({desc:x.desc||'',price: Number(x.price||0)})); render(); } else { alert('Invalid file format (expected an array)'); } }catch(err){ alert('Could not parse file: ' + err.message) } }; reader.readAsText(file); } | |
| function importData(){ const file = importFile.files[0]; if(file) importDataFromFile(file); } | |
| function resetAll(){ if(confirm('Reset all data?')){ data = []; save(); render(); } } | |
| function applyTranslations(){ titleText.textContent = t('title'); addBtn.textContent = t('add'); exportBtn.textContent = t('export'); importBtnText.textContent = t('import'); resetBtn.textContent = t('reset'); thDesc.textContent = t('desc'); thPrice.textContent = t('price'); totalLabelEl.textContent = t('total'); helpText.textContent = t('help'); } | |
| langSelect.value = lang; | |
| langSelect.addEventListener('change', ()=>{ lang = langSelect.value; applyTranslations(); render(); save(); }); | |
| addBtn.addEventListener('click', addRow); | |
| exportBtn.addEventListener('click', exportData); | |
| importFile.addEventListener('change', importData); | |
| resetBtn.addEventListener('click', resetAll); | |
| load(); applyTranslations(); render(); | |
| document.querySelector('.file-input-label').addEventListener('click', ()=> importFile.click()); | |
| window.addEventListener('keydown',(e)=>{ if((e.ctrlKey||e.metaKey) && e.key.toLowerCase()==='n'){ e.preventDefault(); addRow(); } }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment