NOVEMBER
TOTAL ACCOUNT BALANCE
₹0.00
Across all your accounts
INCOME THIS MONTH
₹0.00
Money received
EXPENSES THIS MONTH
₹0.00
Money spent

Monthly overview

your month
Add transactions to see your monthly picture.

Where it goes

expenses
Your category summary will appear here.

Recent transactions

Your first entry is waiting to be added.

Transaction register

the details
DateDescriptionCategoryAccountTypeAmount

Your accounts

Categories

Monthly budgets

spend mindfully

Set spending limits by category. Budget tracking is part of this first version.

Data & preferences

your records

All entries are saved locally in this browser using local storage. No account or cloud sync is configured.

Important: browser data can be lost if site data is cleared or the browser profile is removed. Download backups regularly. This prototype does not yet implement password or biometric locking.

`);w.document.close(); } function downloadBackup(){downloadBlob(JSON.stringify(data,null,2),'personal-ledger-backup.json','application/json')} function downloadBlob(content,name,type){let a=document.createElement('a'),u=URL.createObjectURL(new Blob([content],{type}));a.href=u;a.download=name;a.click();URL.revokeObjectURL(u)} function restoreBackup(e){let f=e.target.files[0];if(!f)return;let r=new FileReader();r.onload=()=>{try{let d=JSON.parse(r.result);if(!Array.isArray(d.accounts)||!Array.isArray(d.transactions)||!Array.isArray(d.categories))throw Error();if(confirm('Replace current local data with this backup?')){data=d;save();alert('Backup restored.')}}catch(err){alert('This backup file is not valid.')}};r.readAsText(f);e.target.value=''} function showPage(id){document.querySelectorAll('.page').forEach(p=>p.classList.toggle('active',p.id===id));document.querySelectorAll('[data-page]').forEach(b=>b.classList.toggle('active',b.dataset.page===id));let titles={overview:['YOUR FINANCES, IN ORDER','A clearer view of your money.'],transactions:['YOUR RECORDS','Every entry, in one place.'],accounts:['YOUR MONEY, ORGANised','The accounts you use.'],categories:['YOUR WAY OF TRACKING','Categories that make sense to you.'],budgets:['PLAN WITH INTENTION','Give every category a limit.'],settings:['YOUR DATA','Keep a copy of your records.']};if(document.getElementById('pageEyebrow'))document.getElementById('pageEyebrow').textContent=titles[id][0];if(document.getElementById('pageTitle'))document.getElementById('pageTitle').textContent=titles[id][1];if(id==='transactions')renderTransactions()} document.querySelectorAll('[data-page]').forEach(b=>b.addEventListener('click',()=>showPage(b.dataset.page))); // Kannada voice capture uses the native Android SpeechRecognizer bridge. // Every recognized utterance becomes an editable draft; it is never saved automatically. let voiceContinuous = false; let voiceDraftCounter = 0; const kannadaDigits = {'೦':'0','೧':'1','೨':'2','೩':'3','೪':'4','೫':'5','೬':'6','೭':'7','೮':'8','೯':'9'}; function normalizeVoiceText(s){return String(s||'').replace(/[೦-೯]/g,c=>kannadaDigits[c]).replace(/ರೂಪಾಯಿ|ರೂಪಾಯಿಗಳು|ರುಪಾಯಿ/g,' ರೂಪಾಯಿ ').replace(/\s+/g,' ').trim()} function openVoicePanel(){document.getElementById('voiceBackdrop').classList.add('open')} function closeVoicePanel(){stopVoice();document.getElementById('voiceBackdrop').classList.remove('open')} function voiceStatus(s){document.getElementById('voiceStatus').textContent=s} let browserRecognizer = null; function startVoice(continuous){ voiceContinuous=!!continuous; voiceStatus(continuous?'Listening continuously… press Stop listening when finished.':'Listening… speak the transaction details.'); if(window.AndroidSpeech){window.AndroidSpeech.start(voiceContinuous);return} const SpeechAPI=window.SpeechRecognition||window.webkitSpeechRecognition; if(!SpeechAPI){ voiceStatus('Speech recognition is not available in this browser. Try Chrome or use manual entry.'); return; } try{ if(browserRecognizer){try{browserRecognizer.abort()}catch(e){}} browserRecognizer=new SpeechAPI(); browserRecognizer.lang='kn-IN'; browserRecognizer.interimResults=false; browserRecognizer.continuous=voiceContinuous; browserRecognizer.maxAlternatives=3; browserRecognizer.onstart=()=>voiceStatus(voiceContinuous?'Listening continuously… press Stop listening when finished.':'Listening…'); browserRecognizer.onerror=(e)=>voiceStatus('Speech recognition: '+(e.error||'please try again')); browserRecognizer.onresult=(event)=>{ for(let i=event.resultIndex;i{ if(voiceContinuous && browserRecognizer && !browserRecognizer.__stopped){ setTimeout(()=>{try{browserRecognizer.start()}catch(e){}},350); }else if(!voiceContinuous){voiceStatus('Speech recognition finished. Review the draft below.')} }; browserRecognizer.__stopped=false; browserRecognizer.start(); }catch(e){voiceStatus('Could not start the microphone: '+e.message)} } function stopVoice(){ voiceContinuous=false; if(window.AndroidSpeech)window.AndroidSpeech.stop(); if(browserRecognizer){browserRecognizer.__stopped=true;try{browserRecognizer.stop()}catch(e){}} voiceStatus('Voice recognition stopped.') } window.onNativeSpeechStatus=function(s){voiceStatus(s)}; window.onNativeSpeechError=function(s){voiceStatus('Speech recognition issue: '+s)}; window.onNativeSpeechResult=function(raw){ const parsed=parseKannadaTransaction(raw); addVoiceDraft(parsed,raw); if(!voiceContinuous)voiceStatus('Speech recognized. Review the transaction details before saving.'); else voiceStatus('Transaction draft added. You can speak another transaction; nothing is saved until you confirm.'); }; function kannadaNumberValue(text){ const t=String(text||'').replace(/\s+/g,' ').trim(); const map={'ಸೊನ್ನೆ':0,'ಶೂನ್ಯ':0,'ಒಂದು':1,'ಒಂದ':1,'ಎರಡು':2,'ಮೂರು':3,'ನಾಲ್ಕು':4,'ಐದು':5,'ಆರು':6,'ಏಳು':7,'ಎಂಟು':8,'ಒಂಬತ್ತು':9,'ಹತ್ತು':10,'ಹನ್ನೊಂದು':11,'ಹನ್ನೆರಡು':12,'ಹದಿಮೂರು':13,'ಹದಿನಾಲ್ಕು':14,'ಹದಿನೈದು':15,'ಹದಿನಾರು':16,'ಹದಿನೇಳು':17,'ಹದಿನೆಂಟು':18,'ಹತ್ತೊಂಬತ್ತು':19,'ಇಪ್ಪತ್ತು':20,'ಮೂವತ್ತು':30,'ನಲವತ್ತು':40,'ಐವತ್ತು':50,'ಅರವತ್ತು':60,'ಎಪ್ಪತ್ತು':70,'ಎಂಬತ್ತು':80,'ತೊಂಬತ್ತು':90,'ನೂರು':100,'ನೂರ':100,'ಸಾವಿರ':1000}; if(Object.prototype.hasOwnProperty.call(map,t))return map[t]; const parts=t.split(' '); let total=0, current=0; for(const w of parts){if(map[w]!==undefined){const n=map[w]; if(n===100||n===1000){current=Math.max(1,current)*n; if(n===1000){total+=current;current=0}}else current+=n;} } return total+current; } function parseSpokenAmount(s){ const marked=s.match(/(?:₹\s*|\b)(\d+(?:,\d{3})*(?:\.\d+)?)\s*(?:ರೂಪಾಯಿ|ರೂಪಾಯಿಗಳು|ರುಪಾಯಿ|ರೂಪಾಯಿಯನ್ನು|ರೂಪಾಯಿಗಳ|rs\.?|rupees?)/i); if(marked)return Number(marked[1].replace(/,/g,'')); const words=['ಸೊನ್ನೆ','ಶೂನ್ಯ','ಒಂದು','ಒಂದ','ಎರಡು','ಮೂರು','ನಾಲ್ಕು','ಐದು','ಆರು','ಏಳು','ಎಂಟು','ಒಂಬತ್ತು','ಹತ್ತು','ಹನ್ನೊಂದು','ಹನ್ನೆರಡು','ಹದಿಮೂರು','ಹದಿನಾಲ್ಕು','ಹದಿನೈದು','ಹದಿನಾರು','ಹದಿನೇಳು','ಹದಿನೆಂಟು','ಹತ್ತೊಂಬತ್ತು','ಇಪ್ಪತ್ತು','ಮೂವತ್ತು','ನಲವತ್ತು','ಐವತ್ತು','ಅರವತ್ತು','ಎಪ್ಪತ್ತು','ಎಂಬತ್ತು','ತೊಂಬತ್ತು','ನೂರು','ಸಾವಿರ']; const re=new RegExp('('+words.join('|')+')\\s*(?:ರೂಪಾಯಿ|ರೂಪಾಯಿಗಳು|ರುಪಾಯಿ|ರೂಪಾಯಿಯನ್ನು|ರೂಪಾಯಿಗಳ)','i'); const m=s.match(re); return m?kannadaNumberValue(m[1]):''; } function parseSpokenDate(s){ const months={'ಜನವರಿ':'01','ಫೆಬ್ರವರಿ':'02','ಮಾರ್ಚ್':'03','ಏಪ್ರಿಲ್':'04','ಮೇ':'05','ಜೂನ್':'06','ಜುಲೈ':'07','ಆಗಸ್ಟ್':'08','ಸೆಪ್ಟೆಂಬರ್':'09','ಅಕ್ಟೋಬರ್':'10','ನವೆಂಬರ್':'11','ಡಿಸೆಂಬರ್':'12','january':'01','february':'02','march':'03','april':'04','may':'05','june':'06','july':'07','august':'08','september':'09','october':'10','november':'11','december':'12'}; let m=s.match(/\b(20\d{2})[-/](\d{1,2})[-/](\d{1,2})\b/); if(m)return `${m[1]}-${String(m[2]).padStart(2,'0')}-${String(m[3]).padStart(2,'0')}`; const monthEntry=Object.entries(months).find(([name])=>s.toLowerCase().includes(name.toLowerCase())); if(monthEntry){const [name,mm]=monthEntry;const after=s.slice(s.toLowerCase().indexOf(name.toLowerCase())+name.length);const before=s.slice(0,s.toLowerCase().indexOf(name.toLowerCase()));let dayMatch=after.match(/\b(\d{1,2})\b/)||before.match(/\b(\d{1,2})\b/);let day=dayMatch?Number(dayMatch[1]):0; if(!day){const ord=after.match(/(ಒಂದು|ಎರಡು|ಮೂರು|ನಾಲ್ಕು|ಐದು|ಆರು|ಏಳು|ಎಂಟು|ಒಂಬತ್ತು|ಹತ್ತು|ಹನ್ನೊಂದು|ಹನ್ನೆರಡು|ಹದಿಮೂರು|ಹದಿನಾಲ್ಕು|ಹದಿನೈದು|ಹದಿನಾರು|ಹದಿನೇಳು|ಹದಿನೆಂಟು|ಹತ್ತೊಂಬತ್ತು|ಇಪ್ಪತ್ತು|ಮೂವತ್ತು|ನಲವತ್ತು|ಐವತ್ತು|ಅರವತ್ತು|ಎಪ್ಪತ್ತು|ಎಂಬತ್ತು|ತೊಂಬತ್ತು)\s*(?:ನೇ|ನೆಯ|ರಂದು|ನೇ ತಾರೀಕು|ನೇ ತಾರೀಖು)?/);if(ord)day=kannadaNumberValue(ord[1]);} if(day>=1&&day<=31){let year=(s.match(/\b(20\d{2})\b/)||[])[1]||new Date().getFullYear();return `${year}-${mm}-${String(day).padStart(2,'0')}`;} } if(/ನಿನ್ನೆ|yesterday/i.test(s)){let d=new Date();d.setDate(d.getDate()-1);return localISODate(d)} if(/ನಾಳೆ|tomorrow/i.test(s)){let d=new Date();d.setDate(d.getDate()+1);return localISODate(d)} return localISODate(new Date()); } function localISODate(d){return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`} function parseKannadaTransaction(raw){ const s=normalizeVoiceText(raw), low=s.toLowerCase(); let amount=parseSpokenAmount(s); let type='Expense'; if(/ಆದಾಯ|ಸಂಬಳ|salary|received|ಬಂದಿದೆ|ಪಡೆದೆ|ಪಡೆದಿದ್ದೇನೆ/i.test(s)) type='Income'; if(/ಕಳುಹಿಸಿದೆ|ವರ್ಗಾವಣೆ|transfer|ಒಂದು ಖಾತೆಯಿಂದ|sent to/i.test(s)) type='Transfer'; let category='Other'; const categories=[ [/grocer|grocery|kirana|ಕಿರಾಣಿ|ದಿನಸಿ|ತರಕಾರಿ|ಸಂತೆ/i,'Groceries'], [/food|hotel|restaurant|ಊಟ|ತಿಂಡಿ|ಹೋಟೆಲ್/i,'Food'], [/petrol|fuel|ಇಂಧನ|ಪೆಟ್ರೋಲ್/i,'Petrol'], [/travel|bus|ಮೆಟ್ರೋ|auto|cab|ಪ್ರಯಾಣ/i,'Travel'], [/family|ಕುಟುಂಬ|ಮನೆಗೆ|ಮನೆಯವರಿಗೆ/i,'Family'], [/bill|ವಿದ್ಯುತ್|ಕರೆಂಟ್|recharge|ರೀಚಾರ್ಜ್/i,'Bills'], [/medical|medicine|ಔಷಧಿ|ಆಸ್ಪತ್ರೆ/i,'Health'], [/shopping|ಬಟ್ಟೆ|ಖರೀದಿ|ಖರೀದಿಸಿದೆ|ಶಾಪಿಂಗ್/i,'Shopping'] ]; for(const [re,c] of categories){if(re.test(s)){category=c;break}} let method='UPI'; if(/cash|ನಗದು/i.test(s)) method='Cash'; else if(/credit card|ಕ್ರೆಡಿಟ್ ಕಾರ್ಡ್/i.test(s)) method='Credit card'; else if(/debit card|ಡೆಬಿಟ್ ಕಾರ್ಡ್/i.test(s)) method='Debit card'; else if(/bank transfer|ಬ್ಯಾಂಕ್ ವರ್ಗಾವಣೆ/i.test(s)) method='Bank transfer'; let account=data.accounts.find(a=>/cash|ನಗದು/i.test(s)?/cash/i.test(a.name):/card/i.test(method)?/card|credit/i.test(a.name):/upi/i.test(a.name)) || data.accounts[0]; const date=parseSpokenDate(s); let description=category==='Other'?'Voice transaction':category; if(/grocer|grocery|kirana|ಕಿರಾಣಿ|ದಿನಸಿ|ತರಕಾರಿ/i.test(s))description='Groceries'; if(/salary|ಸಂಬಳ/i.test(s))description='Salary'; let person=''; const personMatch=s.match(/(?:for|ಗೆ|ವರೆಗೆ)\s+([\p{L} ]{2,30})/u); if(personMatch)person=personMatch[1].trim(); return {type,amount,date,category,description,person,method,account:account.id,accountName:account.name,raw,needsReview:!amount}; } function addVoiceDraft(p,raw){ const id='voiceDraft'+(++voiceDraftCounter); const wrap=document.getElementById('voiceDrafts'); if(wrap.querySelector('.empty'))wrap.innerHTML=''; const accountOptions=data.accounts.map(a=>``).join(''); const categoryOptions=data.categories.map(c=>``).join(''); const el=document.createElement('div');el.className='voice-draft';el.id=id; el.innerHTML=`
Review · Draft ${voiceDraftCounter}Not saved
Voice details · review before saving
`; wrap.prepend(el); } function discardVoiceDraft(id){document.getElementById(id)?.remove();const w=document.getElementById('voiceDrafts');if(!w.children.length)w.innerHTML='
No transaction detected yet.
'} function saveVoiceDraft(id){ const el=document.getElementById(id),v=k=>el.querySelector(`[data-k="${k}"]`).value; const amount=Number(v('amount')),date=v('date'),type=v('type'),account=data.accounts.find(a=>a.id===v('account')); if(!(amount>0)||!date||!account){alert('Please check the amount, date and account.');return} const target={id:crypto.randomUUID(),type,amount,date,account:account.id,accountName:account.name,toAccount:null,toAccountName:null,category:type==='Transfer'?'Transfer':v('category'),description:v('description').trim()||'Voice transaction',person:v('person').trim(),method:v('method'),receipt:null}; data.transactions.push(target);save();discardVoiceDraft(id);voiceStatus('Transaction saved. Review the next draft if there is one.'); } function parseCSVRows(text){ const rows=[];let row=[],cell='',quoted=false; for(let i=0;ix.trim()))rows.push(row);row=[];cell=''} else cell+=c; } row.push(cell);if(row.some(x=>x.trim()))rows.push(row);return rows; } function importTransactionsCSV(e){ const file=e.target.files[0];if(!file)return; const reader=new FileReader(); reader.onload=()=>{ try{ const rows=parseCSVRows(String(reader.result||'').replace(/^\uFEFF/,'')); if(rows.length<2)throw Error('No transaction rows found'); const headers=rows[0].map(x=>x.trim().toLowerCase()); const col=(...names)=>headers.findIndex(h=>names.some(n=>h.includes(n))); const dateI=col('date','transaction date'),amountI=col('amount','debit','credit'),descI=col('description','details','narration','merchant'),typeI=col('type','transaction type'),catI=col('category'),methodI=col('payment method','mode'); if(dateI<0||amountI<0)throw Error('CSV must include Date and Amount (or Debit/Credit) columns'); const account=data.accounts.find(a=>/upi/i.test(a.name))||data.accounts[0]; let added=0,skipped=0; rows.slice(1).forEach(r=>{ const rawDate=(r[dateI]||'').trim();let date=''; if(/^\d{4}-\d{2}-\d{2}$/.test(rawDate))date=rawDate; else {const m=rawDate.match(/^(\d{1,2})[\/-](\d{1,2})[\/-](\d{2,4})$/);if(m)date=`${m[3].length===2?'20'+m[3]:m[3]}-${m[2].padStart(2,'0')}-${m[1].padStart(2,'0')}`} let amountText=(r[amountI]||'').replace(/[₹,\s]/g,''); let amount=Math.abs(Number(amountText)); if(!date||!(amount>0)){skipped++;return} const desc=descI>=0?(r[descI]||'Imported transaction').trim():'Imported transaction'; const tRaw=typeI>=0?(r[typeI]||'').toLowerCase():''; const type=/credit|income|received/i.test(tRaw)?'Income':'Expense'; let category=catI>=0?(r[catI]||'Other').trim():'Other'; if(!data.categories.includes(category)){data.categories.push(category)} const method=methodI>=0?(r[methodI]||'UPI').trim():'UPI'; const duplicate=data.transactions.some(t=>t.date===date&&Math.abs(t.amount-amount)<0.005&&t.description===desc); if(duplicate){skipped++;return} data.transactions.push({id:crypto.randomUUID(),type,amount,date,account:account.id,accountName:account.name,toAccount:null,toAccountName:null,category:type==='Income'?'Income':category,description:desc,person:'',method,receipt:null}); added++; }); save();alert(`Imported ${added} transaction(s). Skipped ${skipped} invalid or duplicate row(s).`); }catch(err){alert('CSV import failed: '+err.message)} e.target.value=''; }; reader.readAsText(file); } // Install through the browser menu; no in-app install button is shown. if('serviceWorker' in navigator && (location.protocol==='https:'||location.hostname==='localhost')){ window.addEventListener('load',()=>navigator.serviceWorker.register('./sw.js').catch(err=>console.warn('PWA offline setup:',err))); } renderAll();