Portfolio Tracker

Track every property you own.

Add each property. Purchase, loan, rent, expenses, key dates, bank accounts. See per-property equity, LVR, yield and cashflow. See the whole portfolio totalled at the bottom. Save it. Export to CSV or PDF for your accountant.

Your saved portfolios
Save this portfolio for later
Saved on this device only. Cross-device sync coming soonget notified when accounts launch.
Property Value Equity LVR Yield Cashflow /yr
Compare two saved portfolios
Pick two saved snapshots (e.g. "Q1 2026" vs "Q2 2026") to see what changed — value, equity, debt, rent, cashflow — both per property and at the portfolio level. Useful for quarterly reviews or "before this purchase / after" analysis.
What this tracks (and what it doesn't)

What this is for: A point-in-time snapshot of your actual real-life portfolio. Add each property you own, fill in the details, and see your portfolio totals + per-property metrics in one place. Save multiple versions (quarterly snapshots, "before this purchase / after this purchase"), export to CSV for your accountant, or PDF for your broker.

What gets auto-calculated:

  • Equity = current value − loan balance
  • LVR = loan balance ÷ current value (lower is better; banks like <80%)
  • Gross yield = annual rent ÷ current value
  • Annual cashflow = rent − interest − all expenses − vacancy adjustment. Pre-tax. Negative gearing benefit isn't auto-applied because it depends on your marginal rate and ownership structure; use CGT & Structure for that.
  • Portfolio totals at the bottom — total value, debt, equity, gross rent, cashflow, average LVR and yield

What this doesn't model:

  • Land tax — varies by state, requires property aggregation, and is sensitive to ownership structure. Use CGT & Structure for state-by-state land tax modelling.
  • Depreciation — needs your QS schedule. Add manually under "Annual depreciation claim" if you have one.
  • Capital growth projections — this is a snapshot, not a forecast. Use Portfolio Planner for that.
  • Auto-valuations — you have to update "current value" yourself. Cross-device sync + auto-valuation pulls from market data will come with the SaaS launch.

Honest use cases: Quarterly portfolio review. Pre-EOFY checklist for your accountant. Refinance package for your broker. Spousal/partner overview. "Should I buy another?" sanity check (look at total LVR + total cashflow). Multi-property landlords who track in Excel today — this replaces that spreadsheet.

`; } // ============================================================ // SAVE / LOAD // ============================================================ const STORAGE_KEY = 'realreturn.portfolio-tracker.portfolios'; function loadAllSaved() { try { const raw = localStorage.getItem(STORAGE_KEY); return raw ? JSON.parse(raw) : []; } catch (e) { return []; } } function persistAll(list) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(list)); } catch (e) { alert('Could not save — browser storage may be full or disabled.'); } } function captureState() { return { properties: JSON.parse(JSON.stringify(properties)) }; } function restoreState(state) { if (!state || !Array.isArray(state.properties)) return; properties = JSON.parse(JSON.stringify(state.properties)); // Reassign IDs if missing properties.forEach(p => { if (!p.id) p.id = nextPropId++; else if (p.id >= nextPropId) nextPropId = p.id + 1; }); expandedIds = new Set(); render(); } function renderSavedList() { const wrap = $('savedList'); const all = loadAllSaved(); if (all.length === 0) { wrap.innerHTML = '
No saved portfolios yet. Name one above and hit Save.
'; return; } wrap.innerHTML = all.map((entry, idx) => { const date = new Date(entry.savedAt); const dateStr = date.toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }); const count = (entry.state.properties || []).length; return `
${escapeHtml(entry.name)}
${count} ${count === 1 ? 'property' : 'properties'} · saved ${dateStr}
`; }).join(''); } $('saveBtn').addEventListener('click', () => { const name = ($('saveName').value || '').trim(); if (!name) { alert('Please give this portfolio a name first.'); $('saveName').focus(); return; } if (properties.length === 0) { alert('Add at least one property before saving.'); return; } const all = loadAllSaved(); const existingIdx = all.findIndex(e => e.name === name); if (existingIdx >= 0) { if (!confirm(`A portfolio called "${name}" already exists. Replace it?`)) return; all.splice(existingIdx, 1); } all.unshift({ name, savedAt: Date.now(), state: captureState() }); persistAll(all); $('saveName').value = ''; renderSavedList(); // Hook: trigger email-capture prompts after successful save try { if (window.RR && window.RR.onSave) window.RR.onSave(document.querySelector('.save-panel')); } catch (_) {} }); $('savedList').addEventListener('click', (e) => { const loadBtn = e.target.closest('[data-load]'); const deleteBtn = e.target.closest('[data-delete]'); if (loadBtn) { const idx = parseInt(loadBtn.dataset.load, 10); const all = loadAllSaved(); const entry = all[idx]; if (entry) restoreState(entry.state); } else if (deleteBtn) { const idx = parseInt(deleteBtn.dataset.delete, 10); const all = loadAllSaved(); const entry = all[idx]; if (!entry) return; if (!confirm(`Delete the portfolio "${entry.name}"? This can't be undone.`)) return; all.splice(idx, 1); persistAll(all); renderSavedList(); } }); // ============================================================ // COMPARE SNAPSHOTS // ============================================================ function refreshCompareDropdowns() { const all = loadAllSaved(); const before = $('compareBefore'); const after = $('compareAfter'); if (!before || !after) return; const optionsHTML = '' + all.map((entry, idx) => { const date = new Date(entry.savedAt); const dateStr = date.toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }); return ``; }).join(''); const beforeVal = before.value; const afterVal = after.value; before.innerHTML = optionsHTML; after.innerHTML = optionsHTML; if (beforeVal) before.value = beforeVal; if (afterVal) after.value = afterVal; } function fmtDelta(before, after, isMoney = true) { const delta = after - before; if (Math.abs(delta) < 0.5) return { text: '—', cls: 'neutral' }; const sign = delta > 0 ? '+' : '−'; const abs = Math.abs(delta); const text = sign + (isMoney ? fmt$k(abs).replace('$', '$') : abs.toFixed(1) + '%'); // For LVR, increase is "warn"; for value/equity/rent, increase is "good" return { text, cls: delta > 0 ? 'good' : 'warn', delta }; } function fmtDeltaLVR(before, after) { const delta = (after - before) * 100; if (Math.abs(delta) < 0.05) return { text: '—', cls: 'neutral' }; const sign = delta > 0 ? '+' : '−'; const text = sign + Math.abs(delta).toFixed(1) + 'pp'; // Higher LVR is worse return { text, cls: delta > 0 ? 'warn' : 'good' }; } function fmtDeltaCF(before, after) { const delta = after - before; if (Math.abs(delta) < 0.5) return { text: '—', cls: 'neutral' }; const sign = delta > 0 ? '+' : '−'; const text = sign + fmt$k(Math.abs(delta)).replace('$', '$'); return { text, cls: delta > 0 ? 'good' : 'warn' }; } function matchProperties(beforeProps, afterProps) { // Match by ID first, then by name/address const beforeById = new Map(); const afterById = new Map(); beforeProps.forEach(p => beforeById.set(p.id, p)); afterProps.forEach(p => afterById.set(p.id, p)); const matched = []; const usedAfterIds = new Set(); const usedBeforeIds = new Set(); // 1. Match by ID beforeProps.forEach(b => { if (afterById.has(b.id)) { matched.push({ before: b, after: afterById.get(b.id), status: 'matched' }); usedBeforeIds.add(b.id); usedAfterIds.add(b.id); } }); // 2. Match remaining by name/address (lowercase, trimmed) const normalize = (p) => ((p.nickname || '') + '|' + (p.address || '')).toLowerCase().trim(); beforeProps.forEach(b => { if (usedBeforeIds.has(b.id)) return; const key = normalize(b); if (!key || key === '|') return; const match = afterProps.find(a => !usedAfterIds.has(a.id) && normalize(a) === key); if (match) { matched.push({ before: b, after: match, status: 'matched' }); usedBeforeIds.add(b.id); usedAfterIds.add(match.id); } }); // 3. Unmatched before = removed beforeProps.forEach(b => { if (!usedBeforeIds.has(b.id)) { matched.push({ before: b, after: null, status: 'removed' }); } }); // 4. Unmatched after = added afterProps.forEach(a => { if (!usedAfterIds.has(a.id)) { matched.push({ before: null, after: a, status: 'added' }); } }); return matched; } function runCompare() { const all = loadAllSaved(); const beforeIdx = $('compareBefore').value; const afterIdx = $('compareAfter').value; const results = $('compareResults'); if (beforeIdx === '' || afterIdx === '') { results.innerHTML = '
Pick a Before and After snapshot above, then click Compare.
'; return; } if (beforeIdx === afterIdx) { results.innerHTML = '
Pick two different snapshots to compare.
'; return; } const beforeEntry = all[parseInt(beforeIdx, 10)]; const afterEntry = all[parseInt(afterIdx, 10)]; if (!beforeEntry || !afterEntry) { results.innerHTML = '
Couldn\'t load one of the snapshots. Try refreshing.
'; return; } const beforeProps = beforeEntry.state.properties || []; const afterProps = afterEntry.state.properties || []; // Calculate totals for both const calcSnapTotals = (props) => { const t = { value: 0, debt: 0, equity: 0, annualRent: 0, netCashflow: 0, count: props.length, avgLVR: 0, avgYield: 0 }; props.forEach(p => { const m = calcMetrics(p); t.value += m.value; t.debt += m.debt; t.equity += m.equity; t.annualRent += m.annualRent; t.netCashflow += m.netCashflow; }); t.avgLVR = t.value > 0 ? t.debt / t.value : 0; t.avgYield = t.value > 0 ? t.annualRent / t.value : 0; return t; }; const tB = calcSnapTotals(beforeProps); const tA = calcSnapTotals(afterProps); const beforeDate = new Date(beforeEntry.savedAt).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }); const afterDate = new Date(afterEntry.savedAt).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }); const valueDelta = fmtDelta(tB.value, tA.value); const equityDelta = fmtDelta(tB.equity, tA.equity); const debtDelta = fmtDelta(tB.debt, tA.debt); // Debt going down is "good" — flip the sign if (debtDelta.delta !== undefined) { debtDelta.cls = debtDelta.delta > 0 ? 'warn' : 'good'; } const cfDelta = fmtDeltaCF(tB.netCashflow, tA.netCashflow); const lvrDelta = fmtDeltaLVR(tB.avgLVR, tA.avgLVR); const rentDelta = fmtDelta(tB.annualRent, tA.annualRent); const propCountDelta = tA.count - tB.count; let summaryHTML = `
${escapeHtml(beforeEntry.name)} (${beforeDate}) ${escapeHtml(afterEntry.name)} (${afterDate}) ${propCountDelta !== 0 ? ` · ${propCountDelta > 0 ? '+' : ''}${propCountDelta} ${Math.abs(propCountDelta) === 1 ? 'property' : 'properties'}` : ''}
Total value ${fmt$k(tA.value)} ${valueDelta.text}
Total equity ${fmt$k(tA.equity)} ${equityDelta.text}
Total debt ${fmt$k(tA.debt)} ${debtDelta.text}
Avg LVR ${fmtPct(tA.avgLVR)} ${lvrDelta.text}
Annual rent ${fmt$k(tA.annualRent)} ${rentDelta.text}
Net cashflow /yr ${fmt$k(tA.netCashflow)} ${cfDelta.text}
Avg yield ${fmtPct(tA.avgYield)} ${fmtDelta(tB.avgYield * 100, tA.avgYield * 100, false).text.replace('$', '')}
Properties ${tA.count} ${propCountDelta > 0 ? '+' : ''}${propCountDelta || '—'}
`; // Per-property comparison const matched = matchProperties(beforeProps, afterProps); let rowsHTML = ''; matched.forEach(({ before, after, status }) => { const ref = after || before; const name = ref.nickname || ref.address || 'Untitled property'; const sublabel = `${ref.propType} · ${ref.state}`; const mB = before ? calcMetrics(before) : { value: 0, debt: 0, equity: 0, annualRent: 0, netCashflow: 0, lvr: 0 }; const mA = after ? calcMetrics(after) : { value: 0, debt: 0, equity: 0, annualRent: 0, netCashflow: 0, lvr: 0 }; let statusBadge = ''; if (status === 'added') statusBadge = 'Added'; if (status === 'removed') statusBadge = 'Removed'; if (status === 'matched') { const vDelta = fmtDelta(mB.value, mA.value); const eDelta = fmtDelta(mB.equity, mA.equity); const dDelta = fmtDelta(mB.debt, mA.debt); if (dDelta.delta !== undefined) dDelta.cls = dDelta.delta > 0 ? 'warn' : 'good'; const lvrD = fmtDeltaLVR(mB.lvr, mA.lvr); const cfDelta2 = fmtDeltaCF(mB.netCashflow, mA.netCashflow); rowsHTML += ` ${escapeHtml(name)}
${escapeHtml(sublabel)} ${vDelta.text} ${eDelta.text} ${dDelta.text} ${lvrD.text} ${cfDelta2.text} `; } else if (status === 'added') { rowsHTML += ` ${escapeHtml(name)} ${statusBadge}
${escapeHtml(sublabel)} +${fmt$k(mA.value)} +${fmt$k(mA.equity)} +${fmt$k(mA.debt)} ${fmtPct(mA.lvr)} ${mA.netCashflow >= 0 ? '+' : '−'}${fmt$k(Math.abs(mA.netCashflow))} `; } else if (status === 'removed') { rowsHTML += ` ${escapeHtml(name)} ${statusBadge}
${escapeHtml(sublabel)} −${fmt$k(mB.value)} −${fmt$k(mB.equity)} −${fmt$k(mB.debt)} — ${mB.netCashflow >= 0 ? '−' : '+'}${fmt$k(Math.abs(mB.netCashflow))} `; } }); const tableHTML = `
${rowsHTML}
Property Δ Value Δ Equity Δ Debt Δ LVR Δ Cashflow
`; results.innerHTML = summaryHTML + tableHTML; } $('runCompareBtn').addEventListener('click', runCompare); // Refresh dropdowns when save list changes const origRenderSavedList = renderSavedList; renderSavedList = function() { origRenderSavedList(); refreshCompareDropdowns(); }; // ============================================================ // INIT // ============================================================ render(); renderSavedList(); // ============================================================ // SHARE VIA URL // ============================================================ function encodeStateForURL(state) { try { const json = JSON.stringify(state); // Use base64url-safe encoding const b64 = btoa(unescape(encodeURIComponent(json))) .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); return b64; } catch (e) { return null; } } function decodeStateFromURL(encoded) { try { // Reverse base64url encoding let b64 = encoded.replace(/-/g, '+').replace(/_/g, '/'); // Re-add padding while (b64.length % 4) b64 += '='; const json = decodeURIComponent(escape(atob(b64))); return JSON.parse(json); } catch (e) { return null; } } function generateShareLink() { const state = captureState(); const encoded = encodeStateForURL(state); if (!encoded) { alert('Could not generate share link. Try saving first, then sharing.'); return null; } const url = `${window.location.origin}${window.location.pathname}#s=${encoded}`; return url; } function showShareSuccess(message) { const banner = document.getElementById('shareBanner'); if (banner) { banner.textContent = '✓ ' + message; banner.classList.add('show'); setTimeout(() => { banner.classList.remove('show'); }, 4000); } const btn = document.getElementById('shareBtn'); if (btn) { const orig = btn.innerHTML; btn.innerHTML = '✓ Copied'; btn.classList.add('success'); setTimeout(() => { btn.innerHTML = orig; btn.classList.remove('success'); }, 2000); } } function setupShareButton() { const btn = document.getElementById('shareBtn'); if (!btn) return; btn.addEventListener('click', async () => { const url = generateShareLink(); if (!url) return; if (url.length > 6000) { if (!confirm('This share link is quite long (browsers may truncate it). Continue?')) return; } try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(url); showShareSuccess('Link copied — paste it anywhere'); } else { // Fallback: show a prompt with the URL prompt('Copy this share link:', url); } } catch (e) { prompt('Copy this share link:', url); } }); } // Check URL hash on load for shared state function checkSharedURL() { const hash = window.location.hash; if (hash && hash.startsWith('#s=')) { const encoded = hash.substring(3); const state = decodeStateFromURL(encoded); if (state) { // Wait a tick for page init to complete, then restore setTimeout(() => { try { restoreState(state); // Clear hash so it doesn't re-apply on refresh history.replaceState(null, '', window.location.pathname); const banner = document.getElementById('shareBanner'); if (banner) { banner.textContent = '✓ Loaded shared scenario — feel free to edit, save, or share again'; banner.classList.add('show'); setTimeout(() => { banner.classList.remove('show'); }, 6000); } } catch (e) { console.error('Failed to restore shared state:', e); } }, 100); } } } setupShareButton(); checkSharedURL();