Skip to content

Instantly share code, notes, and snippets.

@EncodeTheCode
Created March 7, 2026 05:08
Show Gist options
  • Select an option

  • Save EncodeTheCode/07650ed68e60391dc4c6b07293edff1e to your computer and use it in GitHub Desktop.

Select an option

Save EncodeTheCode/07650ed68e60391dc4c6b07293edff1e to your computer and use it in GitHub Desktop.
<!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,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;') }
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