feat: recurring budget entries (lazy materialise) + preferred Satispay badge
This commit is contained in:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user