feat: recurring budget entries (lazy materialise) + preferred Satispay badge

This commit is contained in:
Davide Scaini
2026-06-03 10:47:58 +02:00
parent cf7ce027b1
commit 08f451ec71
2 changed files with 76 additions and 13 deletions
+45 -2
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json import json
import uuid import uuid
from datetime import date
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request from fastapi import APIRouter, Cookie, Depends, HTTPException, Request
@@ -36,9 +37,44 @@ def _save(data: dict) -> None:
) )
def _materialise_recurring(data: dict) -> bool:
"""Auto-add a copy of each recurring entry for the current month if absent.
Returns True if data was modified (caller should save).
Copies reference the template via recurring_from so they're not re-generated.
"""
current = date.today().strftime("%Y-%m")
templates = [e for e in data.get("entries", []) if e.get("recurring")]
if not templates:
return False
modified = False
for t in templates:
if t["month"] == current:
continue # template is already this month
already = any(
e.get("recurring_from") == t["id"] and e["month"] == current
for e in data["entries"]
)
if not already:
data["entries"].append({
"id": str(uuid.uuid4())[:8],
"type": t["type"],
"label": t["label"],
"amount_eur": t["amount_eur"],
"month": current,
"note": t.get("note", ""),
"recurring_from": t["id"],
})
modified = True
return modified
@router.get("/api/budget") @router.get("/api/budget")
async def get_budget() -> JSONResponse: async def get_budget() -> JSONResponse:
return JSONResponse(_load()) data = _load()
if _materialise_recurring(data):
_save(data)
return JSONResponse(data)
@router.post("/api/budget/settings") @router.post("/api/budget/settings")
@@ -76,7 +112,7 @@ async def add_entry(
raise HTTPException(400, "month must be YYYY-MM") raise HTTPException(400, "month must be YYYY-MM")
note = str(body.get("note", "")).strip() note = str(body.get("note", "")).strip()
entry = { entry: dict = {
"id": str(uuid.uuid4())[:8], "id": str(uuid.uuid4())[:8],
"type": entry_type, "type": entry_type,
"label": label, "label": label,
@@ -84,6 +120,8 @@ async def add_entry(
"month": month, "month": month,
"note": note, "note": note,
} }
if body.get("recurring"):
entry["recurring"] = True
data = _load() data = _load()
data.setdefault("entries", []).append(entry) data.setdefault("entries", []).append(entry)
_save(data) _save(data)
@@ -113,6 +151,11 @@ async def update_entry(
entry["month"] = str(body["month"]).strip() entry["month"] = str(body["month"]).strip()
if "note" in body: if "note" in body:
entry["note"] = str(body["note"]).strip() entry["note"] = str(body["note"]).strip()
if "recurring" in body:
if body["recurring"]:
entry["recurring"] = True
else:
entry.pop("recurring", None)
_save(data) _save(data)
return JSONResponse(entry) return JSONResponse(entry)
+31 -11
View File
@@ -23,12 +23,17 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
style="background:#FF5E5B; color:#fff;"> style="background:#FF5E5B; color:#fff;">
☕ Support on Ko-fi ☕ Support on Ko-fi
</a> </a>
<a href="https://web.satispay.com/download/qrcode/S6Y-CON--BE9BD345-4499-4C1D-9AC3-D62FC5FF0AD4" <div class="relative">
target="_blank" rel="noopener noreferrer" <a href="https://web.satispay.com/download/qrcode/S6Y-CON--BE9BD345-4499-4C1D-9AC3-D62FC5FF0AD4"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium hover:opacity-90 transition-opacity" target="_blank" rel="noopener noreferrer"
style="background:#E3162C; color:#fff;"> class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"
Satispay @brutsalvadi style="background:#E3162C; color:#fff;">
</a> Satispay @brutsalvadi
</a>
<span class="absolute -top-2 -right-2 px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-green-500 text-white leading-none">
preferred
</span>
</div>
</div> </div>
<div class="mb-8"> <div class="mb-8">
<img src="/satispay-qr.jpg" alt="Satispay QR code — @brutsalvadi" class="w-36 h-36 rounded-xl" /> <img src="/satispay-qr.jpg" alt="Satispay QR code — @brutsalvadi" class="w-36 h-36 rounded-xl" />
@@ -84,6 +89,10 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
</div> </div>
<input id="entry-note" type="text" placeholder="Note (optional)" <input id="entry-note" type="text" placeholder="Note (optional)"
class="w-full px-3 py-1.5 rounded-lg bg-zinc-800 border border-zinc-700 text-white text-sm focus:outline-none focus:border-[--accent]" /> class="w-full px-3 py-1.5 rounded-lg bg-zinc-800 border border-zinc-700 text-white text-sm focus:outline-none focus:border-[--accent]" />
<label class="flex items-center gap-2 text-xs text-zinc-400 cursor-pointer select-none">
<input id="entry-recurring" type="checkbox" class="accent-[--accent] w-3.5 h-3.5" />
Repeat every month
</label>
<p id="budget-add-err" class="text-red-400 text-xs hidden"></p> <p id="budget-add-err" class="text-red-400 text-xs hidden"></p>
<div class="flex gap-2 pt-1"> <div class="flex gap-2 pt-1">
<button type="submit" class="px-4 py-1.5 rounded-lg bg-[--accent] hover:opacity-90 text-white text-sm font-medium transition-opacity">Add</button> <button type="submit" class="px-4 py-1.5 rounded-lg bg-[--accent] hover:opacity-90 text-white text-sm font-medium transition-opacity">Add</button>
@@ -410,9 +419,14 @@ function renderBudget() {
html += `<div class="divide-y divide-zinc-800/60 rounded-xl border border-zinc-800 overflow-hidden">`; html += `<div class="divide-y divide-zinc-800/60 rounded-xl border border-zinc-800 overflow-hidden">`;
for (const e of items) { for (const e of items) {
const icon = e.type === 'donation' ? '💚' : '🔴'; const icon = e.type === 'donation' ? '💚' : '🔴';
const sign = e.type === 'donation' ? '+' : ''; const sign = e.type === 'donation' ? '+' : '';
const color = e.type === 'donation' ? 'text-green-400' : 'text-red-400'; const color = e.type === 'donation' ? 'text-green-400' : 'text-red-400';
const recurBadge = e.recurring
? `<span class="text-zinc-500 text-xs shrink-0" title="Repeats every month">↻</span>`
: e.recurring_from
? `<span class="text-zinc-600 text-xs shrink-0" title="Auto-added (recurring)">↻</span>`
: '';
const adminBtns = isAdmin const adminBtns = isAdmin
? `<button data-edit="${e.id}" class="edit-btn text-zinc-600 hover:text-zinc-300 text-xs px-1 transition-colors">✎</button> ? `<button data-edit="${e.id}" class="edit-btn text-zinc-600 hover:text-zinc-300 text-xs px-1 transition-colors">✎</button>
<button data-del="${e.id}" class="del-btn text-zinc-700 hover:text-red-400 text-xs px-1 transition-colors">✕</button>` <button data-del="${e.id}" class="del-btn text-zinc-700 hover:text-red-400 text-xs px-1 transition-colors">✕</button>`
@@ -422,6 +436,7 @@ function renderBudget() {
<span class="flex-1 text-zinc-300 min-w-0"> <span class="flex-1 text-zinc-300 min-w-0">
${e.label}${e.note ? `<span class="text-zinc-600 text-xs ml-2">${e.note}</span>` : ''} ${e.label}${e.note ? `<span class="text-zinc-600 text-xs ml-2">${e.note}</span>` : ''}
</span> </span>
${recurBadge}
<span class="font-medium shrink-0 ${color}">${sign}${fmtEur(e.amount_eur)}</span> <span class="font-medium shrink-0 ${color}">${sign}${fmtEur(e.amount_eur)}</span>
${adminBtns} ${adminBtns}
</div>`; </div>`;
@@ -464,6 +479,9 @@ function renderBudget() {
class="px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none [color-scheme:dark]" /> class="px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none [color-scheme:dark]" />
<input name="note" value="${entry.note}" placeholder="Note" <input name="note" value="${entry.note}" placeholder="Note"
class="flex-1 min-w-24 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none" /> class="flex-1 min-w-24 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none" />
<label class="flex items-center gap-1 text-xs text-zinc-400 cursor-pointer">
<input type="checkbox" name="recurring" ${entry.recurring?'checked':''} class="accent-[--accent] w-3 h-3" />↻
</label>
<button type="submit" class="px-3 py-1 rounded bg-[--accent] text-white text-xs hover:opacity-90">Save</button> <button type="submit" class="px-3 py-1 rounded bg-[--accent] text-white text-xs hover:opacity-90">Save</button>
<button type="button" class="cancel-edit px-2 py-1 rounded bg-zinc-800 text-zinc-400 text-xs hover:bg-zinc-700">✕</button> <button type="button" class="cancel-edit px-2 py-1 rounded bg-zinc-800 text-zinc-400 text-xs hover:bg-zinc-700">✕</button>
</form>`; </form>`;
@@ -471,7 +489,7 @@ function renderBudget() {
row.querySelector<HTMLFormElement>('.inline-edit-form')!.addEventListener('submit', async ev => { row.querySelector<HTMLFormElement>('.inline-edit-form')!.addEventListener('submit', async ev => {
ev.preventDefault(); ev.preventDefault();
const fd = new FormData(ev.target as HTMLFormElement); const fd = new FormData(ev.target as HTMLFormElement);
const body = { type: fd.get('type'), label: fd.get('label'), amount_eur: parseFloat(fd.get('amount_eur') as string), month: fd.get('month'), note: fd.get('note') }; const body = { type: fd.get('type'), label: fd.get('label'), amount_eur: parseFloat(fd.get('amount_eur') as string), month: fd.get('month'), note: fd.get('note'), recurring: fd.get('recurring') === 'on' };
const r = await fetch(`/api/budget/entries/${id}`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const r = await fetch(`/api/budget/entries/${id}`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (r.ok) { const updated = await r.json(); const idx = budgetData!.entries.findIndex(e => e.id === id); if (idx !== -1) budgetData!.entries[idx] = updated; renderBudget(); } if (r.ok) { const updated = await r.json(); const idx = budgetData!.entries.findIndex(e => e.id === id); if (idx !== -1) budgetData!.entries[idx] = updated; renderBudget(); }
}); });
@@ -533,11 +551,13 @@ addForm.addEventListener('submit', async e => {
const note = (document.getElementById('entry-note') as HTMLInputElement).value.trim(); const note = (document.getElementById('entry-note') as HTMLInputElement).value.trim();
if (!label) { addErr.textContent = 'Label is required.'; addErr.classList.remove('hidden'); return; } if (!label) { addErr.textContent = 'Label is required.'; addErr.classList.remove('hidden'); return; }
if (isNaN(amount)) { addErr.textContent = 'Amount is required.'; addErr.classList.remove('hidden'); return; } if (isNaN(amount)) { addErr.textContent = 'Amount is required.'; addErr.classList.remove('hidden'); return; }
const r = await fetch('/api/budget/entries', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: addEntryType, label, amount_eur: amount, month, note }) }); const recurring = (document.getElementById('entry-recurring') as HTMLInputElement).checked;
const r = await fetch('/api/budget/entries', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: addEntryType, label, amount_eur: amount, month, note, recurring: recurring || undefined }) });
if (r.ok) { if (r.ok) {
budgetData!.entries.push(await r.json()); budgetData!.entries.push(await r.json());
addForm.classList.add('hidden'); addForm.reset(); addForm.classList.add('hidden'); addForm.reset();
(document.getElementById('entry-month') as HTMLInputElement).value = defaultMonth; (document.getElementById('entry-month') as HTMLInputElement).value = defaultMonth;
(document.getElementById('entry-recurring') as HTMLInputElement).checked = false;
(addForm.querySelector('.entry-type-btn[data-type="donation"]') as HTMLButtonElement).click(); (addForm.querySelector('.entry-type-btn[data-type="donation"]') as HTMLButtonElement).click();
renderBudget(); renderBudget();
} else { } else {