Initial commit — Moon Household Budget

This commit is contained in:
Bonna
2026-06-06 14:22:57 -05:00
commit fa3c030b36
44 changed files with 10860 additions and 0 deletions

484
templates/weekly.html Normal file
View File

@@ -0,0 +1,484 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1>{{ month_name }} {{ year }} — Weekly Overview</h1>
<div class="month-nav">
<a href="{{ url_for('weekly_overview', year=prev_year, month=prev_month) }}" class="btn btn-ghost"> Prev</a>
<a href="{{ url_for('weekly_overview', year=next_year, month=next_month) }}" class="btn btn-ghost">Next </a>
</div>
</div>
<div class="week-tabs">
{% for w in weeks %}
<button class="week-tab" onclick="showWeek({{ w.num }})" id="tab-{{ w.num }}">
Week {{ w.num }}<br><span>{{ w.start_label }} {{ w.end_label }}</span>
</button>
{% endfor %}
</div>
{% for w in weeks %}
<div class="week-panel" id="week-{{ w.num }}">
<!-- Week summary -->
{% if w.summary %}
<div style="background:var(--warm-white);border:1px solid var(--border);border-radius:var(--radius);padding:0.85rem 1.1rem;margin-bottom:1rem;display:flex;flex-wrap:wrap;gap:0.5rem 1.5rem;">
<span style="font-size:0.75rem;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-light);width:100%;">How's this week looking?</span>
{% for line in w.summary %}
<span style="font-size:0.88rem;color:var(--text-dark);">{{ line | bold_md }}</span>
{% endfor %}
</div>
{% endif %}
<!-- Starting Balance -->
<div style="display:flex;align-items:center;gap:1rem;background:var(--warm-white);border:1px solid var(--border);border-radius:var(--radius);padding:0.75rem 1rem;margin-bottom:1rem;">
<label style="font-weight:600;white-space:nowrap;">Checking Balance at Start of Week</label>
<input type="number" step="0.01" class="budget-input" style="max-width:160px;"
id="starting-balance-{{ w.num }}"
value="{{ w.starting_balance }}"
onchange="saveStartingBalance({{ year }}, {{ month }}, '{{ w.num }}', this.value)"
placeholder="0.00">
<span style="font-size:0.82rem;color:var(--text-light);">Enter what's in checking when the week begins</span>
</div>
<!-- 1. Income -->
<div class="budget-section">
<div class="budget-section-header weekly-collapsible" onclick="toggleWeeklySection(this)" style="border-left-color: {{ colors.get('Income', '#A0C5A8') }};cursor:pointer;">
<h3><span class="weekly-arrow"></span> Income</h3>
</div>
<div class="weekly-section-body">
<table class="budget-table">
<thead><tr><th>Source</th><th>Budgeted</th><th>Actual</th></tr></thead>
<tbody>
{% for src in w.income_sources %}
{% set inc = w.income.get(src, {}) %}
<tr>
<td>
{{ src }}
{% if src == 'Auto King' %}
<span class="income-friday-tag">Fridays</span>
{% endif %}
</td>
<td><input type="number" step="0.01" class="budget-input"
value="{{ inc.get('budgeted', '') }}"
onchange="saveField({{ year }}, {{ month }}, '{{ w.num }}', 'income', '{{ src }}', 'budgeted', this.value)"
placeholder="expected"></td>
<td>
<div class="income-actual-wrap">
<input type="number" step="0.01" class="budget-input income-actual-input"
id="income-actual-{{ w.num }}-{{ src | replace(' ','_') }}"
value="{{ inc.get('actual', '') }}"
onchange="logIncome({{ year }}, {{ month }}, '{{ w.num }}', '{{ src }}', this.value)"
placeholder="enter when received">
{% if inc.get('actual') %}
<span class="income-logged-badge">✓ logged</span>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td>Total</td>
<td class="amount">$<span id="w{{ w.num }}-income-budget-total">{{ (w.income.values() | map(attribute='budgeted') | select | map('float') | sum if w.income else 0) | money }}</span></td>
<td class="amount">$<span id="w{{ w.num }}-income-actual-total">{{ w.income_total | money }}</span></td>
</tr>
</tfoot>
</table>
</div>
</div>
<!-- 2. Bills -->
<div class="budget-section">
<div class="budget-section-header weekly-collapsible" onclick="toggleWeeklySection(this)" style="border-left-color: {{ colors.get('Debt', '#C5A0A0') }};cursor:pointer;">
<h3><span class="weekly-arrow"></span> Bills Due This Week</h3>
<a href="{{ url_for('bills_list') }}" class="edit-link" onclick="event.stopPropagation()">manage bills</a>
</div>
<div class="weekly-section-body">
{% if w.bills %}
<table class="budget-table">
<thead><tr><th>Bill</th><th>Due</th><th>Amount</th><th>Category</th></tr></thead>
<tbody>
{% for b in w.bills %}
<tr>
<td>{{ b.name }}</td>
<td class="muted">{{ b.due_date.strftime('%b %-d') if b.due_date else '' }}</td>
<td class="amount">{% if b.amount %}${{ b.amount | money }}{% else %}<span class="muted"></span>{% endif %}</td>
<td>
{% if b.category %}
<span class="tag" style="background: {{ colors.get(b.category, '#ddd') }};">{{ b.category }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="2">Bills Total</td>
<td class="amount">${{ w.bills_total | money }}</td>
<td></td>
</tr>
</tfoot>
</table>
{% else %}
<p class="muted" style="padding: 0.75rem 0;">No bills due this week.</p>
{% endif %}
</div>
</div>
<!-- 3. Budget Categories -->
<div class="budget-section">
<div class="budget-section-header weekly-collapsible" onclick="toggleWeeklySection(this)" style="border-left-color: {{ colors.get('Food', '#A8C5A0') }};cursor:pointer;">
<h3><span class="weekly-arrow"></span> Budget Categories</h3>
</div>
<div class="weekly-section-body">
{% set skip = ['Income', 'Debt', 'Transfer'] %}
{% set total_budgeted = namespace(v=0) %}
{% set total_actual = namespace(v=0) %}
<table class="budget-table">
<thead><tr><th>Category</th><th>Budgeted</th><th>Spent</th><th>Left to Spend</th></tr></thead>
<tbody>
{% for cat in categories if cat not in skip %}
{% set cb = w.cat_budget.get(cat, {}) %}
{% set actual = w.cat_actuals.get(cat, 0) %}
{% set budgeted = cb.get('budgeted', '') | float %}
{% set left = budgeted - actual %}
{% set over = budgeted > 0 and actual > budgeted %}
{% set total_budgeted.v = total_budgeted.v + budgeted %}
{% set total_actual.v = total_actual.v + actual %}
<tr{% if over %} style="background:#fdf0f0;"{% endif %}>
<td>
<span class="tag" style="background: {{ colors.get(cat, '#ddd') }};">{{ cat }}</span>
</td>
<td><input type="number" step="0.01" class="budget-input"
value="{{ cb.get('budgeted', '') }}"
onchange="saveField({{ year }}, {{ month }}, '{{ w.num }}', 'categories', '{{ cat }}', 'budgeted', this.value)"
placeholder="0.00"></td>
<td class="amount{% if actual == 0 %} muted{% endif %}">
${{ actual | money }}
</td>
<td class="amount">
{% if budgeted %}
{% if over %}
<span style="color:#c07070;">-${{ (actual - budgeted) | money }}</span>
{% else %}
<span style="color:#5A9E68;">${{ left | money }}</span>
{% endif %}
{% else %}
<span class="muted"></span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
{% set total_left = total_budgeted.v - total_actual.v %}
<tr class="total-row">
<td>Totals</td>
<td class="amount">${{ total_budgeted.v | money }}</td>
<td class="amount">${{ total_actual.v | money }}</td>
<td class="amount">
{% if total_budgeted.v > 0 %}
{% if total_left < 0 %}
<span style="color:#c07070;font-weight:700;">-${{ total_left | abs | money }} over</span>
{% else %}
<span style="color:#5A9E68;font-weight:700;">${{ total_left | money }} left</span>
{% endif %}
{% endif %}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
<!-- 4. Sinking Funds -->
<div class="budget-section">
<div class="budget-section-header weekly-collapsible weekly-collapsed" onclick="toggleWeeklySection(this)" style="border-left-color: {{ colors.get('Travel', '#C5BBA0') }};cursor:pointer;">
<h3><span class="weekly-arrow"></span> Sinking Funds</h3>
<a href="{{ url_for('sinking_view') }}" class="edit-link" onclick="event.stopPropagation()">manage funds</a>
</div>
<div class="weekly-section-body" style="display:none;">
<table class="budget-table">
<thead><tr><th>Fund</th><th>Current Balance</th><th>Add This Week</th></tr></thead>
<tbody>
{% for f in sinking_funds %}
{% set wkey = 'sinking_' ~ w.num ~ '_' ~ f.name %}
{% set sinking_entry = w.sinking.get(f.name, {}) %}
{% set contributed = sinking_entry.get('contributed', '') %}
{% set from_txn = sinking_entry.get('from_transaction') %}
<tr>
<td>
<span class="tag" style="background: {{ fund_colors.get(f.name, '#C5BBA0') }};">{{ f.name }}</span>
</td>
<td class="amount">${{ (f.balance or 0) | money }}</td>
<td>
<div class="income-actual-wrap">
<input type="number" step="0.01" class="budget-input"
value="{{ contributed }}"
onchange="contributeSinking('{{ f.name }}', this.value, {{ year }}, {{ month }}, '{{ w.num }}')"
placeholder="0.00"
{% if from_txn %}title="Logged automatically from a categorized transaction"{% endif %}>
{% if from_txn %}
<span class="income-logged-badge" title="This was recorded when you categorized a transaction">✓ from transaction</span>
{% elif contributed %}
<span class="income-logged-badge">✓ added</span>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="2">Total Contributed This Week</td>
<td class="amount">${{ (w.sinking.values() | selectattr('contributed') | map(attribute='contributed') | map('float') | sum) | money }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
<!-- 5. Extra Debt Payments -->
<div class="budget-section">
<div class="budget-section-header weekly-collapsible weekly-collapsed" onclick="toggleWeeklySection(this)" style="border-left-color: {{ colors.get('Debt', '#C5A0A0') }};cursor:pointer;">
<h3><span class="weekly-arrow"></span> Extra Debt Payments</h3>
<a href="{{ url_for('debts_view') }}" class="edit-link" onclick="event.stopPropagation()">manage debts</a>
</div>
<div class="weekly-section-body" style="display:none;">
{% if active_debts %}
<table class="budget-table">
<thead><tr><th>Debt</th><th>Owner</th><th>Minimum</th><th>Extra Payment</th></tr></thead>
<tbody>
{% for d in active_debts %}
{% set extra_paid = w.extra_debt.get(d.id, {}).get('extra', '') %}
<tr>
<td>{{ d.name }}</td>
<td><span class="owner-tag" style="background:{{ owner_colors.get(d.owner,'#ddd') }};font-size:0.72rem;">{{ d.owner }}</span></td>
<td class="amount muted">${{ (d.minimum or 0) | money }}</td>
<td>
<div class="income-actual-wrap">
<input type="number" step="0.01" class="budget-input"
value="{{ extra_paid }}"
onchange="logExtraDebt('{{ d.id }}', this.value, {{ year }}, {{ month }}, '{{ w.num }}')"
placeholder="0.00">
{% if extra_paid %}
<span class="income-logged-badge">✓ logged</span>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="3">Total Extra This Week</td>
<td class="amount accent">${{ (w.extra_debt.values() | map(attribute='extra') | select | map('float') | sum) | money }}</td>
</tr>
</tfoot>
</table>
{% else %}
<p class="muted" style="padding:0.75rem 0;">No debts added yet. <a href="{{ url_for('debts_view') }}">Add debts</a> to track extra payments here.</p>
{% endif %}
</div>
</div>
<!-- 6. Transactions -->
<div class="budget-section">
<div class="budget-section-header weekly-collapsible weekly-collapsed" onclick="toggleWeeklySection(this)" style="border-left-color:#D4A96A;cursor:pointer;">
<h3><span class="weekly-arrow"></span> Transactions This Week{% if w.transactions %} <span style="font-weight:400;font-size:0.82rem;color:var(--text-light);">({{ w.transactions | length }})</span>{% endif %}</h3>
</div>
<div class="weekly-section-body" style="display:none;">
<!-- Inline import -->
<details style="margin-bottom:1rem;">
<summary class="btn" style="display:inline-block;cursor:pointer;font-size:0.85rem;background:#7A9E7E;color:#fff;border:none;">⬆️ Import Statement</summary>
<div style="margin-top:0.75rem;background:var(--cream);border:1px solid var(--border);border-radius:var(--radius);padding:1rem;">
<form method="POST" action="{{ url_for('import_transactions') }}" enctype="multipart/form-data">
<input type="hidden" name="redirect_to" value="weekly">
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;align-items:flex-end;">
<div>
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Account Name</label>
<input type="text" name="account" placeholder="e.g. WestConsin Checking" class="budget-input" style="width:200px;" required>
</div>
<div>
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Statement File (PDF or CSV)</label>
<input type="file" name="statement" accept=".pdf,.csv" required>
</div>
<button type="submit" class="btn btn-primary" style="font-size:0.85rem;">Import</button>
</div>
</form>
</div>
</details>
{% if w.transactions %}
{% set txns_by_cat = {} %}
{% for t in w.transactions %}
{% set cat = t.category or 'Uncategorized' %}
{% if cat not in txns_by_cat %}{% set _ = txns_by_cat.update({cat: []}) %}{% endif %}
{% set _ = txns_by_cat[cat].append(t) %}
{% endfor %}
<table class="budget-table">
<thead><tr><th>Date</th><th>Description</th><th>Category</th><th>Amount</th><th></th></tr></thead>
<tbody>
{% for t in w.transactions | sort(attribute='date', reverse=True) %}
<tr>
<td class="muted" style="white-space:nowrap;font-size:0.82rem;">{{ t.date }}</td>
<td style="font-size:0.85rem;">{{ t.description }}</td>
<td>
{% if t.category %}
<span class="tag" style="background:{{ colors.get(t.category,'#ddd') }};font-size:0.75rem;">{{ t.category }}{% if t.subcategory %} {{ t.subcategory }}{% endif %}</span>
{% else %}
<span class="muted" style="font-size:0.78rem;"></span>
{% endif %}
</td>
<td class="amount" style="font-size:0.85rem;">${{ t.amount | money }}</td>
<td><a href="{{ url_for('edit_transaction', txn_id=t.id) }}" class="edit-link" style="font-size:0.78rem;">Edit</a></td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="3">Total</td>
<td class="amount">${{ (w.transactions | map(attribute='amount') | map('float') | sum) | money }}</td>
<td></td>
</tr>
</tfoot>
</table>
{% else %}
<p class="muted" style="padding:0.5rem 0;">No reviewed transactions for this week yet.</p>
{% endif %}
</div>
</div>
<!-- Leftover -->
<div class="leftover-bar {% if w.leftover < 0 %}leftover-negative{% endif %}" id="leftover-bar-{{ w.num }}">
<div>
<div style="font-weight:600;">Week {{ w.num }} — What's Left</div>
<div style="font-size:0.78rem;color:var(--text-light);margin-top:0.2rem;line-height:1.7;">
$<span id="lo-start-{{ w.num }}">{{ w.starting_balance | money }}</span> starting
+ $<span id="lo-income-{{ w.num }}">{{ w.income_total | money }}</span> income
&minus; $<span id="lo-spending-{{ w.num }}">{{ w.spending_total | money }}</span> spending
&minus; $<span id="lo-sinking-{{ w.num }}">{{ w.sinking_total | money }}</span> savings
&minus; $<span id="lo-debt-{{ w.num }}">{{ w.extra_debt_total | money }}</span> extra debt
</div>
</div>
<span class="leftover-amount" id="leftover-amount-{{ w.num }}"
data-spending="{{ w.spending_total }}"
data-sinking="{{ w.sinking_total }}"
data-debt="{{ w.extra_debt_total }}"
data-start="{{ w.starting_balance }}"
>${{ w.leftover | money }}</span>
</div>
</div><!-- end week-panel -->
{% endfor %}
<script>
function showWeek(num) {
document.querySelectorAll('.week-panel').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.week-tab').forEach(t => t.classList.remove('active'));
document.getElementById('week-' + num).classList.add('active');
document.getElementById('tab-' + num).classList.add('active');
}
function saveField(year, month, week, field, key, subkey, value) {
fetch('/weekly/save', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({year, month, week, field, key, subkey, value})
}).then(() => updateLeftover(week));
}
function logIncome(year, month, week, source, value) {
fetch('/weekly/save', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({year, month, week, field: 'income', key: source, subkey: 'actual', value})
}).then(r => r.json()).then(data => {
updateLeftover(week);
// show ✓ badge
const id = 'income-actual-' + week + '-' + source.replace(/ /g,'_');
const input = document.getElementById(id);
if (input && parseFloat(value) > 0) {
let badge = input.parentElement.querySelector('.income-logged-badge');
if (!badge) {
badge = document.createElement('span');
badge.className = 'income-logged-badge';
badge.textContent = '✓ logged';
input.parentElement.appendChild(badge);
}
}
});
}
function updateLeftover(week) {
// Recalculate income total from inputs
let incomeTotal = 0;
document.querySelectorAll(`#week-${week} .income-actual-input`).forEach(el => {
incomeTotal += parseFloat(el.value) || 0;
});
const totalEl = document.getElementById(`w${week}-income-actual-total`);
if (totalEl) totalEl.textContent = incomeTotal.toFixed(2);
const loIncomeEl = document.getElementById(`lo-income-${week}`);
if (loIncomeEl) loIncomeEl.textContent = incomeTotal.toFixed(2);
// Update leftover bar using all deductions from data attributes
const leftoverEl = document.getElementById(`leftover-amount-${week}`);
if (leftoverEl) {
const startBal = parseFloat(document.getElementById(`starting-balance-${week}`)?.value) || 0;
const spending = parseFloat(leftoverEl.dataset.spending) || 0;
const sinking = parseFloat(leftoverEl.dataset.sinking) || 0;
const debt = parseFloat(leftoverEl.dataset.debt) || 0;
const leftover = startBal + incomeTotal - spending - sinking - debt;
leftoverEl.textContent = '$' + leftover.toFixed(2);
const bar = document.getElementById(`leftover-bar-${week}`);
if (bar) bar.classList.toggle('leftover-negative', leftover < 0);
}
}
function logExtraDebt(debtId, value, year, month, week) {
fetch('/weekly/extra-debt', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({debt_id: debtId, amount: value, year, month, week})
});
}
function saveStartingBalance(year, month, week, value) {
fetch('/weekly/save', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({year, month, week, field: 'starting_balance', key: 'amount', subkey: 'value', value})
}).then(() => {
const loStart = document.getElementById(`lo-start-${week}`);
if (loStart) loStart.textContent = parseFloat(value || 0).toFixed(2);
updateLeftover(week);
});
}
function contributeSinking(fundName, value, year, month, week) {
fetch('/sinking/contribute-weekly', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({name: fundName, amount: value, year, month, week})
}).then(r => r.json()).then(data => {
// update the balance cell
document.querySelectorAll('.sinking-weekly-balance-' + fundName.replace(/ /g,'_'))
.forEach(el => el.textContent = '$' + (data.new_balance || 0).toFixed(2));
});
}
function toggleWeeklySection(header) {
const body = header.nextElementSibling;
const arrow = header.querySelector('.weekly-arrow');
const collapsed = body.style.display === 'none';
body.style.display = collapsed ? '' : 'none';
arrow.textContent = collapsed ? '▾' : '▸';
}
// Show current week by default
showWeek({{ current_week }});
</script>
{% endblock %}