Compare commits
11 commits
aa7ebaa234
...
73825bc305
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73825bc305 | ||
|
|
c2afacfe6e | ||
|
|
fe419fe802 | ||
|
|
1c525f0202 | ||
|
|
a814ec6e01 | ||
|
|
c331211afd | ||
|
|
7de8f918fb | ||
|
|
c96d3f5d2f | ||
|
|
af276f0eec | ||
|
|
6f98618b51 | ||
|
|
ab5b88a52b |
10 changed files with 1878 additions and 241 deletions
1256
docs/superpowers/plans/2026-04-17-edit-budget-template-entries.md
Normal file
1256
docs/superpowers/plans/2026-04-17-edit-budget-template-entries.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -10,7 +10,7 @@ from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from quartermaster import month_service, service
|
from quartermaster import month_service, service
|
||||||
from quartermaster.db import get_session
|
from quartermaster.db import get_session
|
||||||
from quartermaster.models import SECTION_LABELS, Section
|
from quartermaster.models import SECTION_LABELS, Entry, Section
|
||||||
|
|
||||||
TEMPLATES_DIR = Path(__file__).parent / "templates"
|
TEMPLATES_DIR = Path(__file__).parent / "templates"
|
||||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||||
|
|
@ -39,11 +39,16 @@ def _section_view(db: Session, section: Section) -> service.SectionView:
|
||||||
|
|
||||||
|
|
||||||
def _render_section(
|
def _render_section(
|
||||||
request: Request, db: Session, section: Section
|
request: Request,
|
||||||
|
db: Session,
|
||||||
|
section: Section,
|
||||||
|
editing_id: int | None = None,
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
view = _section_view(db, section)
|
view = _section_view(db, section)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request, "partials/section.html", {"section": view}
|
request,
|
||||||
|
"partials/section.html",
|
||||||
|
{"section": view, "editing_id": editing_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -97,6 +102,7 @@ def index(request: Request, db: Session = Depends(get_session)) -> HTMLResponse:
|
||||||
"all_months": month_service.list_months(db),
|
"all_months": month_service.list_months(db),
|
||||||
"zero": zero,
|
"zero": zero,
|
||||||
"tone": service.zero_tone(zero),
|
"tone": service.zero_tone(zero),
|
||||||
|
"editing_id": None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -144,17 +150,53 @@ def remove_entry(
|
||||||
return _append_oob(response, *extras)
|
return _append_oob(response, *extras)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/entries/{entry_id}/notes", response_class=HTMLResponse)
|
@router.get("/entries/{entry_id}/edit", response_class=HTMLResponse)
|
||||||
def update_entry_notes(
|
def edit_entry(
|
||||||
entry_id: int,
|
entry_id: int,
|
||||||
request: Request,
|
request: Request,
|
||||||
|
db: Session = Depends(get_session),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
entry = db.get(Entry, entry_id)
|
||||||
|
if entry is None:
|
||||||
|
raise HTTPException(status_code=404, detail="entry not found")
|
||||||
|
return _render_section(request, db, entry.section, editing_id=entry.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/entries/{entry_id}", response_class=HTMLResponse)
|
||||||
|
def save_entry(
|
||||||
|
entry_id: int,
|
||||||
|
request: Request,
|
||||||
|
name: str = Form(...),
|
||||||
|
amount: str = Form(...),
|
||||||
notes: str | None = Form(None),
|
notes: str | None = Form(None),
|
||||||
db: Session = Depends(get_session),
|
db: Session = Depends(get_session),
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
updated = service.set_entry_notes(db, entry_id, notes)
|
clean_name = name.strip()
|
||||||
|
if not clean_name:
|
||||||
|
raise HTTPException(status_code=400, detail="name is required")
|
||||||
|
parsed = _parse_amount(amount)
|
||||||
|
updated = service.update_entry(
|
||||||
|
db, entry_id, name=clean_name, amount=parsed, notes=notes
|
||||||
|
)
|
||||||
if updated is None:
|
if updated is None:
|
||||||
raise HTTPException(status_code=404, detail="entry not found")
|
raise HTTPException(status_code=404, detail="entry not found")
|
||||||
return _render_section(request, db, updated.section)
|
response = _render_section(request, db, updated.section)
|
||||||
|
extras: list[HTMLResponse] = [
|
||||||
|
_render_zero(request, db),
|
||||||
|
_render_group_totals(request, db),
|
||||||
|
]
|
||||||
|
if updated.section == Section.debt_minimum:
|
||||||
|
extras.append(_render_target(request, db))
|
||||||
|
return _append_oob(response, *extras)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sections/{section}", response_class=HTMLResponse)
|
||||||
|
def get_section(
|
||||||
|
section: Section,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_session),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
return _render_section(request, db, section)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/debt-target", response_class=HTMLResponse)
|
@router.post("/debt-target", response_class=HTMLResponse)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ from quartermaster.groups import (
|
||||||
)
|
)
|
||||||
from quartermaster.models import SECTION_LABELS, DebtTarget, Entry, Section
|
from quartermaster.models import SECTION_LABELS, DebtTarget, Entry, Section
|
||||||
|
|
||||||
|
_NOTES_SENTINEL = object()
|
||||||
|
|
||||||
|
|
||||||
def zero_tone(value: Decimal) -> str:
|
def zero_tone(value: Decimal) -> str:
|
||||||
if value == 0:
|
if value == 0:
|
||||||
|
|
@ -113,13 +115,23 @@ def _clean_notes(raw: str | None) -> str | None:
|
||||||
return stripped if stripped else None
|
return stripped if stripped else None
|
||||||
|
|
||||||
|
|
||||||
def set_entry_notes(
|
def update_entry(
|
||||||
db: Session, entry_id: int, notes: str | None
|
db: Session,
|
||||||
|
entry_id: int,
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
amount: Decimal | None = None,
|
||||||
|
notes: str | None | object = _NOTES_SENTINEL,
|
||||||
) -> Entry | None:
|
) -> Entry | None:
|
||||||
entry = db.get(Entry, entry_id)
|
entry = db.get(Entry, entry_id)
|
||||||
if entry is None:
|
if entry is None:
|
||||||
return None
|
return None
|
||||||
entry.notes = _clean_notes(notes)
|
if name is not None:
|
||||||
|
entry.name = name.strip()
|
||||||
|
if amount is not None:
|
||||||
|
entry.amount = amount
|
||||||
|
if notes is not _NOTES_SENTINEL:
|
||||||
|
entry.notes = _clean_notes(notes) # type: ignore[arg-type]
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(entry)
|
db.refresh(entry)
|
||||||
return entry
|
return entry
|
||||||
|
|
|
||||||
|
|
@ -361,64 +361,160 @@ details.group[open] > summary .chevron::after { opacity: 0; }
|
||||||
.total .applied { color: var(--ink-soft); }
|
.total .applied { color: var(--ink-soft); }
|
||||||
.total.empty { color: var(--muted); }
|
.total.empty { color: var(--muted); }
|
||||||
|
|
||||||
/* =============== ENTRY TABLE =============== */
|
/* =============== ENTRY LIST (BUDGET TEMPLATE) =============== */
|
||||||
|
|
||||||
table.entries {
|
.budget-entries { display: flex; flex-direction: column; }
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-family: var(--sans);
|
|
||||||
}
|
|
||||||
table.entries thead { display: none; }
|
|
||||||
|
|
||||||
table.entries tbody tr.entry {
|
.entry-row.reading {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 5.5rem 5.5rem 1.2rem;
|
grid-template-columns: minmax(0, 1fr) 5.5rem auto;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
padding: 0.26rem 0.25rem 0.28rem;
|
padding: 0.26rem 0.25rem 0.28rem;
|
||||||
position: relative;
|
|
||||||
border-bottom: 1px dotted var(--rule);
|
border-bottom: 1px dotted var(--rule);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
table.entries tbody tr.entry:hover { background: var(--paper-stripe); }
|
.entry-row.reading:hover { background: var(--paper-stripe); }
|
||||||
table.entries tbody tr.entry td {
|
|
||||||
padding: 0;
|
.entry-row.reading .entry-name {
|
||||||
border: none;
|
font-family: var(--sans);
|
||||||
vertical-align: baseline;
|
font-weight: 500;
|
||||||
|
font-size: 1.02rem;
|
||||||
|
color: var(--ink);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.entry-row.reading .note-badge {
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.entry-row.reading .note-badge::before {
|
||||||
|
content: "· ";
|
||||||
|
color: var(--rule);
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
.entry-row.reading .entry-amount {
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--ink);
|
||||||
|
font-feature-settings: "lnum" 1, "tnum" 1;
|
||||||
|
text-align: right;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
table.entries tbody tr.entry::after {
|
.entry-row.reading .entry-actions {
|
||||||
content: "";
|
display: flex;
|
||||||
position: absolute;
|
gap: 0.3rem;
|
||||||
left: 0; right: 0; bottom: -1px;
|
align-items: center;
|
||||||
height: 2px;
|
|
||||||
background: var(--sage-soft);
|
|
||||||
width: min(100%, calc(var(--ratio, 1) * 100%));
|
|
||||||
transition: width 0.25s ease;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
}
|
||||||
table.entries tbody tr.entry.state-edited::after,
|
.entry-row.reading .entry-actions button {
|
||||||
table.entries tbody tr.entry[data-deviation="over"]::after {
|
background: none;
|
||||||
background: var(--accent);
|
border: none;
|
||||||
opacity: 0.85;
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--rule);
|
||||||
|
opacity: 0;
|
||||||
|
transition: color 0.12s ease, opacity 0.12s ease;
|
||||||
}
|
}
|
||||||
table.entries tbody tr.entry.state-new_in_month::after {
|
.entry-row.reading:hover .entry-actions button { opacity: 1; }
|
||||||
background: var(--indigo);
|
.entry-row.reading .entry-actions button.edit {
|
||||||
opacity: 0.55;
|
font-size: 0.95rem;
|
||||||
|
color: var(--rule);
|
||||||
|
}
|
||||||
|
.entry-row.reading .entry-actions button.edit:hover { color: var(--ink); }
|
||||||
|
.entry-row.reading .entry-actions button.delete {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.entry-row.reading .entry-actions button.delete:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
.entry-row.editing {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 5.5rem minmax(0, 1.4fr) auto;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.26rem 0.25rem 0.28rem;
|
||||||
|
border-bottom: 1px dotted var(--rule);
|
||||||
|
background: var(--paper-soft);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.entry-row.editing input {
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.12s;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.entry-row.editing input:focus { border-color: var(--ink); }
|
||||||
|
.entry-row.editing input[type="number"] {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.entry-row.editing .notes-input {
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.entry-row.editing .entry-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.entry-row.editing .save-btn,
|
||||||
|
.entry-row.editing .cancel-btn {
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0.22rem 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
|
||||||
|
}
|
||||||
|
.entry-row.editing .save-btn {
|
||||||
|
border: 1px solid var(--ink);
|
||||||
|
background: var(--paper-soft);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.entry-row.editing .save-btn:hover {
|
||||||
|
background: var(--sage);
|
||||||
|
color: var(--paper);
|
||||||
|
border-color: var(--sage);
|
||||||
|
}
|
||||||
|
.entry-row.editing .cancel-btn {
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.entry-row.editing .cancel-btn:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
tr.empty td {
|
.empty-row {
|
||||||
padding: 0.4rem 0.5rem !important;
|
padding: 0.4rem 0.5rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
tr.add-row td {
|
.add-row {
|
||||||
padding: 0.4rem 0.25rem 0.2rem !important;
|
padding: 0.4rem 0.25rem 0.2rem;
|
||||||
border-bottom: none !important;
|
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Add-entry disclosure: collapsed trigger, expanded form */
|
/* Add-entry disclosure: collapsed trigger, expanded form */
|
||||||
|
|
@ -455,112 +551,18 @@ details.add-entry > .month-add-form {
|
||||||
margin-top: 0.45rem;
|
margin-top: 0.45rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-name {
|
@media (max-width: 520px) {
|
||||||
font-family: var(--sans);
|
.entry-row.reading {
|
||||||
font-weight: 500;
|
grid-template-columns: minmax(0, 1fr) 4.6rem auto;
|
||||||
font-size: 1.02rem;
|
gap: 0.4rem;
|
||||||
color: var(--ink);
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
.entry-name input {
|
.entry-row.editing {
|
||||||
font: inherit;
|
grid-template-columns: minmax(0, 1fr) 4.6rem;
|
||||||
color: inherit;
|
gap: 0.4rem;
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-bottom: 1px solid transparent;
|
|
||||||
padding: 0;
|
|
||||||
width: 100%;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.12s;
|
|
||||||
}
|
}
|
||||||
.entry-name input:hover { border-bottom-color: var(--rule); }
|
.entry-row.editing .notes-input,
|
||||||
.entry-name input:focus { border-bottom-color: var(--ink); }
|
.entry-row.editing .entry-actions { grid-column: 1 / -1; }
|
||||||
|
|
||||||
.entry-amount {
|
|
||||||
font-family: var(--sans);
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--ink);
|
|
||||||
font-feature-settings: "lnum" 1, "tnum" 1;
|
|
||||||
text-align: right;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
.entry-amount input {
|
|
||||||
font: inherit;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
color: inherit;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-bottom: 1px solid transparent;
|
|
||||||
padding: 0;
|
|
||||||
width: 100%;
|
|
||||||
outline: none;
|
|
||||||
text-align: right;
|
|
||||||
transition: border-color 0.12s;
|
|
||||||
}
|
|
||||||
.entry-amount input:hover { border-bottom-color: var(--rule); }
|
|
||||||
.entry-amount input:focus { border-bottom-color: var(--ink); }
|
|
||||||
|
|
||||||
/* On the budget page the planned-amount cell is the only numeric cell;
|
|
||||||
still render it like a ledger number. */
|
|
||||||
tr.entry td.entry-amount:first-of-type { color: var(--ink); }
|
|
||||||
|
|
||||||
.entry-actions button.delete {
|
|
||||||
font-family: var(--sans);
|
|
||||||
font-size: 1.2rem;
|
|
||||||
line-height: 1;
|
|
||||||
color: var(--rule);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
opacity: 0;
|
|
||||||
transition: color 0.12s ease, opacity 0.12s ease;
|
|
||||||
align-self: center;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
tr.entry:hover .entry-actions button.delete { opacity: 1; }
|
|
||||||
.entry-actions button.delete:hover { color: var(--accent); }
|
|
||||||
|
|
||||||
/* Notes row — hidden when empty, shown on hover or when value is set */
|
|
||||||
tr.entry-notes-row {
|
|
||||||
display: block;
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
font-family: var(--sans);
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 400;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--muted);
|
|
||||||
line-height: 1.3;
|
|
||||||
padding: 0;
|
|
||||||
margin-top: -0.25rem;
|
|
||||||
}
|
|
||||||
tr.entry-notes-row td {
|
|
||||||
padding: 0 0.25rem 0.25rem !important;
|
|
||||||
border-bottom: 1px dotted var(--rule) !important;
|
|
||||||
}
|
|
||||||
tr.entry-notes-row input.notes-input {
|
|
||||||
font: inherit;
|
|
||||||
font-style: italic;
|
|
||||||
color: inherit;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
width: 100%;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
tr.entry-notes-row input.notes-input::placeholder {
|
|
||||||
color: var(--rule);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
/* Empty notes render subtly (placeholder only) so they stay clickable. */
|
|
||||||
tr.entry-notes-row:has(input:placeholder-shown) { opacity: 0.55; }
|
|
||||||
tr.entry-notes-row:hover,
|
|
||||||
tr.entry-notes-row:has(input:focus) { opacity: 1; }
|
|
||||||
|
|
||||||
.tag {
|
.tag {
|
||||||
font-family: var(--sans);
|
font-family: var(--sans);
|
||||||
|
|
@ -578,6 +580,31 @@ tr.entry-notes-row:has(input:focus) { opacity: 1; }
|
||||||
.tag-edited { color: var(--ochre); }
|
.tag-edited { color: var(--ochre); }
|
||||||
.tag-new { color: var(--indigo); }
|
.tag-new { color: var(--indigo); }
|
||||||
|
|
||||||
|
/* Target card keeps the table-based layout from before Task 2 rewrite. */
|
||||||
|
.target-section table.entries { width: 100%; border-collapse: collapse; font-family: var(--sans); }
|
||||||
|
.target-section table.entries thead { display: none; }
|
||||||
|
.target-section table.entries tbody tr.entry {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 5.5rem 5.5rem 1.2rem;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 0.26rem 0.25rem 0.28rem;
|
||||||
|
position: relative;
|
||||||
|
border-bottom: 1px dotted var(--rule);
|
||||||
|
}
|
||||||
|
.target-section table.entries tbody tr.entry td {
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
vertical-align: baseline;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.target-section table.entries tr.add-row td {
|
||||||
|
padding: 0.4rem 0.25rem 0.2rem !important;
|
||||||
|
border-bottom: none !important;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
/* =============== PRIMARY DEBT TARGET =============== */
|
/* =============== PRIMARY DEBT TARGET =============== */
|
||||||
|
|
||||||
.section.target-section {
|
.section.target-section {
|
||||||
|
|
@ -980,13 +1007,6 @@ form.add-posting-form button[type="submit"] {
|
||||||
}
|
}
|
||||||
form.add-posting-form button[type="submit"]:hover { background: var(--ink); color: var(--paper); }
|
form.add-posting-form button[type="submit"]:hover { background: var(--ink); color: var(--paper); }
|
||||||
|
|
||||||
.empty-row {
|
|
||||||
padding: 0.5rem 0.5rem;
|
|
||||||
color: var(--muted);
|
|
||||||
font-style: italic;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.entry-block > summary {
|
.entry-block > summary {
|
||||||
grid-template-columns: 0.9rem minmax(0, 1fr) 4.2rem 7rem 1rem;
|
grid-template-columns: 0.9rem minmax(0, 1fr) 4.2rem 7rem 1rem;
|
||||||
|
|
@ -1118,8 +1138,4 @@ button[disabled] {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
}
|
}
|
||||||
table.entries tbody tr.entry {
|
|
||||||
grid-template-columns: minmax(0, 1fr) 4.6rem 4.6rem 1rem;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,68 @@
|
||||||
${{ '{:,.2f}'.format(section.total) }}
|
${{ '{:,.2f}'.format(section.total) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<table class="entries">
|
<div class="entries budget-entries">
|
||||||
<tbody>
|
|
||||||
{% for entry in section.entries %}
|
{% for entry in section.entries %}
|
||||||
<tr class="entry state-unchanged" style="--ratio: 1">
|
{% if editing_id is not none and entry.id == editing_id %}
|
||||||
<td class="entry-name">{{ entry.name }}</td>
|
<form
|
||||||
<td class="entry-amount">${{ '{:,.2f}'.format(entry.amount) }}</td>
|
class="entry-row editing"
|
||||||
<td class="entry-amount"></td>
|
hx-post="/entries/{{ entry.id }}"
|
||||||
<td class="entry-actions">
|
hx-target="#section-{{ section.section.value }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
class="name-input"
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
value="{{ entry.name }}"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
aria-label="Name"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
class="amount-input"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
name="amount"
|
||||||
|
value="{{ '%.2f' | format(entry.amount) }}"
|
||||||
|
required
|
||||||
|
aria-label="Amount"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
class="notes-input"
|
||||||
|
type="text"
|
||||||
|
name="notes"
|
||||||
|
value="{{ entry.notes or '' }}"
|
||||||
|
placeholder="notes (optional)"
|
||||||
|
aria-label="Notes"
|
||||||
|
>
|
||||||
|
<div class="entry-actions">
|
||||||
|
<button type="submit" class="save-btn">Save</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cancel-btn"
|
||||||
|
hx-get="/sections/{{ section.section.value }}"
|
||||||
|
hx-target="#section-{{ section.section.value }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="entry-row reading">
|
||||||
|
<span class="entry-name">
|
||||||
|
{{ entry.name }}{% if entry.notes %}<span class="note-badge">{{ entry.notes }}</span>{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="entry-amount">${{ '{:,.2f}'.format(entry.amount) }}</span>
|
||||||
|
<div class="entry-actions">
|
||||||
|
<button
|
||||||
|
class="edit"
|
||||||
|
type="button"
|
||||||
|
hx-get="/entries/{{ entry.id }}/edit"
|
||||||
|
hx-target="#section-{{ section.section.value }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
aria-label="Edit {{ entry.name }}"
|
||||||
|
>✎</button>
|
||||||
<button
|
<button
|
||||||
class="delete"
|
class="delete"
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -21,29 +75,13 @@
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
aria-label="Delete {{ entry.name }}"
|
aria-label="Delete {{ entry.name }}"
|
||||||
>×</button>
|
>×</button>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</div>
|
||||||
<tr class="entry-notes-row">
|
{% endif %}
|
||||||
<td colspan="4">
|
|
||||||
<input
|
|
||||||
class="notes-input"
|
|
||||||
type="text"
|
|
||||||
name="notes"
|
|
||||||
value="{{ entry.notes or '' }}"
|
|
||||||
placeholder="notes..."
|
|
||||||
hx-post="/entries/{{ entry.id }}/notes"
|
|
||||||
hx-trigger="change"
|
|
||||||
hx-target="#section-{{ section.section.value }}"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
aria-label="Notes for {{ entry.name }}"
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr class="empty"><td colspan="4">No entries yet.</td></tr>
|
<div class="empty-row">No entries yet.</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<tr class="add-row">
|
<div class="add-row">
|
||||||
<td colspan="4">
|
|
||||||
<details class="add-entry">
|
<details class="add-entry">
|
||||||
<summary><span class="add-trigger">+ add {{ section.label|lower }}</span></summary>
|
<summary><span class="add-trigger">+ add {{ section.label|lower }}</span></summary>
|
||||||
<form
|
<form
|
||||||
|
|
@ -59,8 +97,6 @@
|
||||||
<input class="notes-input add-notes" type="text" name="notes" placeholder="notes (optional)">
|
<input class="notes-input add-notes" type="text" name="notes" placeholder="notes (optional)">
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</div>
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
</div>
|
</div>
|
||||||
<table class="entries">
|
<table class="entries">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr class="entry" style="--ratio: 1">
|
<tr class="entry">
|
||||||
<td class="entry-name">
|
<td class="entry-name">
|
||||||
{% if target.entry %}{{ target.entry.name }}{% else %}<span class="muted">No target selected.</span>{% endif %}
|
{% if target.entry %}{{ target.entry.name }}{% else %}<span class="muted">No target selected.</span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -24,16 +24,6 @@ def test_add_entry_strips_and_nulls_empty_notes(db):
|
||||||
assert entry.notes is None
|
assert entry.notes is None
|
||||||
|
|
||||||
|
|
||||||
def test_set_entry_notes_updates(db):
|
|
||||||
entry = service.add_entry(db, Section.food, "Groceries", Decimal("400.00"))
|
|
||||||
updated = service.set_entry_notes(db, entry.id, "weekly Costco run")
|
|
||||||
assert updated is not None
|
|
||||||
assert updated.notes == "weekly Costco run"
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_entry_notes_missing_returns_none(db):
|
|
||||||
assert service.set_entry_notes(db, 9999, "oops") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_snapshot_copies_notes(db):
|
def test_snapshot_copies_notes(db):
|
||||||
service.add_entry(
|
service.add_entry(
|
||||||
|
|
@ -91,26 +81,39 @@ def test_create_entry_route_accepts_notes(client):
|
||||||
assert "3 mo cushion" in response.text
|
assert "3 mo cushion" in response.text
|
||||||
|
|
||||||
|
|
||||||
def test_update_entry_notes_route(client):
|
def test_update_entry_notes_via_save_route(client):
|
||||||
|
client.post(
|
||||||
|
"/sections/food/entries",
|
||||||
|
data={"name": "Groceries", "amount": "400.00"},
|
||||||
|
)
|
||||||
|
response = client.post(
|
||||||
|
"/entries/1",
|
||||||
|
data={"name": "Groceries", "amount": "400.00", "notes": "weekly"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "weekly" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_entry_notes_empty_clears_via_save_route(client):
|
||||||
|
client.post(
|
||||||
|
"/sections/food/entries",
|
||||||
|
data={"name": "Groceries", "amount": "400.00", "notes": "weekly"},
|
||||||
|
)
|
||||||
|
response = client.post(
|
||||||
|
"/entries/1",
|
||||||
|
data={"name": "Groceries", "amount": "400.00", "notes": ""},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "note-badge" not in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_old_entry_notes_route_is_removed(client):
|
||||||
client.post(
|
client.post(
|
||||||
"/sections/food/entries",
|
"/sections/food/entries",
|
||||||
data={"name": "Groceries", "amount": "400.00"},
|
data={"name": "Groceries", "amount": "400.00"},
|
||||||
)
|
)
|
||||||
response = client.post("/entries/1/notes", data={"notes": "weekly"})
|
response = client.post("/entries/1/notes", data={"notes": "weekly"})
|
||||||
assert response.status_code == 200
|
assert response.status_code == 404
|
||||||
assert "weekly" in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_entry_notes_empty_clears(client):
|
|
||||||
client.post(
|
|
||||||
"/sections/food/entries",
|
|
||||||
data={"name": "Groceries", "amount": "400.00", "notes": "weekly"},
|
|
||||||
)
|
|
||||||
response = client.post("/entries/1/notes", data={"notes": ""})
|
|
||||||
assert response.status_code == 200
|
|
||||||
# the input's value="" still renders but the placeholder kicks in;
|
|
||||||
# specifically, no literal "weekly" anymore
|
|
||||||
assert "value=\"weekly\"" not in response.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_month_entry_route_accepts_notes(client):
|
def test_create_month_entry_route_accepts_notes(client):
|
||||||
|
|
@ -136,17 +139,27 @@ def test_update_month_entry_route_accepts_notes(client):
|
||||||
assert "auto-pay" in response.text
|
assert "auto-pay" in response.text
|
||||||
|
|
||||||
|
|
||||||
def test_budget_page_renders_notes_inputs(client):
|
def test_budget_page_renders_note_badge_when_notes_set(client):
|
||||||
client.post(
|
client.post(
|
||||||
"/sections/fixed_bill/entries",
|
"/sections/fixed_bill/entries",
|
||||||
data={"name": "Rent", "amount": "1200.00", "notes": "due 1st"},
|
data={"name": "Rent", "amount": "1200.00", "notes": "due 1st"},
|
||||||
)
|
)
|
||||||
response = client.get("/")
|
response = client.get("/")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "entry-notes-row" in response.text
|
assert "note-badge" in response.text
|
||||||
assert "due 1st" in response.text
|
assert "due 1st" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_page_omits_note_badge_when_notes_empty(client):
|
||||||
|
client.post(
|
||||||
|
"/sections/food/entries",
|
||||||
|
data={"name": "Groceries", "amount": "400.00"},
|
||||||
|
)
|
||||||
|
response = client.get("/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "note-badge" not in response.text
|
||||||
|
|
||||||
|
|
||||||
def test_month_page_renders_notes_inputs(client):
|
def test_month_page_renders_notes_inputs(client):
|
||||||
client.post(
|
client.post(
|
||||||
"/sections/fixed_bill/entries",
|
"/sections/fixed_bill/entries",
|
||||||
|
|
|
||||||
|
|
@ -86,3 +86,142 @@ def test_reject_non_debt_minimum_target(client):
|
||||||
)
|
)
|
||||||
response = client.post("/debt-target", data={"debt_minimum_id": "1"})
|
response = client.post("/debt-target", data={"debt_minimum_id": "1"})
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_entry_edit_returns_edit_form(client):
|
||||||
|
client.post(
|
||||||
|
"/sections/subscription/entries",
|
||||||
|
data={"name": "Twitch", "amount": "10.99"},
|
||||||
|
)
|
||||||
|
response = client.get("/entries/1/edit")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'class="entry-row editing"' in response.text
|
||||||
|
assert 'name="name"' in response.text
|
||||||
|
assert 'name="amount"' in response.text
|
||||||
|
assert 'name="notes"' in response.text
|
||||||
|
assert 'value="Twitch"' in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_entry_edit_missing_returns_404(client):
|
||||||
|
response = client.get("/entries/9999/edit")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_entry_edit_other_rows_stay_in_read_mode(client):
|
||||||
|
client.post("/sections/subscription/entries", data={"name": "Twitch", "amount": "10.99"})
|
||||||
|
client.post("/sections/subscription/entries", data={"name": "Netflix", "amount": "15.49"})
|
||||||
|
response = client.get("/entries/1/edit")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.text.count('entry-row editing') == 1
|
||||||
|
assert response.text.count('entry-row reading') == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_entry_updates_name_and_amount(client):
|
||||||
|
client.post(
|
||||||
|
"/sections/subscription/entries",
|
||||||
|
data={"name": "Twitch", "amount": "10.99"},
|
||||||
|
)
|
||||||
|
response = client.post(
|
||||||
|
"/entries/1",
|
||||||
|
data={"name": "Twitch Prime", "amount": "11.99", "notes": ""},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Twitch Prime" in response.text
|
||||||
|
assert "$11.99" in response.text
|
||||||
|
# returns to read mode
|
||||||
|
assert 'class="entry-row reading"' in response.text
|
||||||
|
# OOB swaps for zero widget and group total
|
||||||
|
assert 'id="zero-widget"' in response.text
|
||||||
|
assert 'id="group-total-flexible"' in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_entry_updates_notes_as_badge(client):
|
||||||
|
client.post(
|
||||||
|
"/sections/subscription/entries",
|
||||||
|
data={"name": "Spotify", "amount": "17.48"},
|
||||||
|
)
|
||||||
|
response = client.post(
|
||||||
|
"/entries/1",
|
||||||
|
data={"name": "Spotify", "amount": "17.48", "notes": "family plan"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "note-badge" in response.text
|
||||||
|
assert "family plan" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_entry_debt_minimum_includes_target_oob(client):
|
||||||
|
client.post(
|
||||||
|
"/sections/debt_minimum/entries",
|
||||||
|
data={"name": "Card A", "amount": "50.00"},
|
||||||
|
)
|
||||||
|
response = client.post(
|
||||||
|
"/entries/1",
|
||||||
|
data={"name": "Card A", "amount": "60.00", "notes": ""},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'id="section-debt_target"' in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_entry_empty_name_returns_400(client):
|
||||||
|
client.post(
|
||||||
|
"/sections/subscription/entries",
|
||||||
|
data={"name": "Twitch", "amount": "10.99"},
|
||||||
|
)
|
||||||
|
response = client.post(
|
||||||
|
"/entries/1",
|
||||||
|
data={"name": " ", "amount": "11.99", "notes": ""},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_entry_negative_amount_returns_400(client):
|
||||||
|
client.post(
|
||||||
|
"/sections/subscription/entries",
|
||||||
|
data={"name": "Twitch", "amount": "10.99"},
|
||||||
|
)
|
||||||
|
response = client.post(
|
||||||
|
"/entries/1",
|
||||||
|
data={"name": "Twitch", "amount": "-1.00", "notes": ""},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_entry_non_numeric_amount_returns_400(client):
|
||||||
|
client.post(
|
||||||
|
"/sections/subscription/entries",
|
||||||
|
data={"name": "Twitch", "amount": "10.99"},
|
||||||
|
)
|
||||||
|
response = client.post(
|
||||||
|
"/entries/1",
|
||||||
|
data={"name": "Twitch", "amount": "eleven", "notes": ""},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_entry_missing_returns_404(client):
|
||||||
|
response = client.post(
|
||||||
|
"/entries/9999",
|
||||||
|
data={"name": "Whatever", "amount": "1.00", "notes": ""},
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_section_returns_read_mode(client):
|
||||||
|
client.post(
|
||||||
|
"/sections/subscription/entries",
|
||||||
|
data={"name": "Twitch", "amount": "10.99"},
|
||||||
|
)
|
||||||
|
# enter edit mode first
|
||||||
|
edit = client.get("/entries/1/edit")
|
||||||
|
assert 'entry-row editing' in edit.text
|
||||||
|
# now "cancel" via GET /sections/{section}
|
||||||
|
response = client.get("/sections/subscription")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'entry-row reading' in response.text
|
||||||
|
assert 'entry-row editing' not in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_section_invalid_returns_422(client):
|
||||||
|
# FastAPI rejects an unknown Section enum value at routing
|
||||||
|
response = client.get("/sections/not_a_real_section")
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
|
||||||
|
|
@ -41,3 +41,83 @@ def test_debt_target_cleared_on_delete(db):
|
||||||
service.delete_entry(db, dm.id)
|
service.delete_entry(db, dm.id)
|
||||||
target = service.get_debt_target(db)
|
target = service.get_debt_target(db)
|
||||||
assert target.debt_minimum_id is None
|
assert target.debt_minimum_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_entry_name_only(db):
|
||||||
|
entry = service.add_entry(db, Section.subscription, "Twitch", Decimal("10.99"))
|
||||||
|
updated = service.update_entry(db, entry.id, name="Twitch Prime")
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.name == "Twitch Prime"
|
||||||
|
assert updated.amount == Decimal("10.99")
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_entry_amount_only(db):
|
||||||
|
entry = service.add_entry(db, Section.subscription, "Twitch", Decimal("10.99"))
|
||||||
|
updated = service.update_entry(db, entry.id, amount=Decimal("11.99"))
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.name == "Twitch"
|
||||||
|
assert updated.amount == Decimal("11.99")
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_entry_notes_set_and_clear(db):
|
||||||
|
entry = service.add_entry(db, Section.other, "Parking", Decimal("25.00"))
|
||||||
|
updated = service.update_entry(db, entry.id, notes="work")
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.notes == "work"
|
||||||
|
updated = service.update_entry(db, entry.id, notes="")
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.notes is None
|
||||||
|
service.update_entry(db, entry.id, notes="work again")
|
||||||
|
updated = service.update_entry(db, entry.id, notes=None)
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.notes is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_entry_all_three_atomic(db):
|
||||||
|
entry = service.add_entry(db, Section.food, "Groceries", Decimal("400.00"))
|
||||||
|
updated = service.update_entry(
|
||||||
|
db,
|
||||||
|
entry.id,
|
||||||
|
name="Groceries (Costco)",
|
||||||
|
amount=Decimal("450.00"),
|
||||||
|
notes="weekly run",
|
||||||
|
)
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.name == "Groceries (Costco)"
|
||||||
|
assert updated.amount == Decimal("450.00")
|
||||||
|
assert updated.notes == "weekly run"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_entry_notes_untouched_when_sentinel(db):
|
||||||
|
entry = service.add_entry(
|
||||||
|
db, Section.other, "Gift", Decimal("25.00"), notes="birthday"
|
||||||
|
)
|
||||||
|
updated = service.update_entry(db, entry.id, amount=Decimal("30.00"))
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.notes == "birthday"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_entry_missing_returns_none(db):
|
||||||
|
assert service.update_entry(db, 9999, name="Whatever") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_entry_does_not_mutate_existing_month_snapshot(db):
|
||||||
|
from quartermaster import month_service
|
||||||
|
entry = service.add_entry(
|
||||||
|
db, Section.subscription, "Twitch", Decimal("10.99")
|
||||||
|
)
|
||||||
|
month = month_service.create_month(db, "2026-04")
|
||||||
|
me = next(e for e in month.entries if e.source_entry_id == entry.id)
|
||||||
|
assert me.planned == Decimal("10.99")
|
||||||
|
assert me.origin_planned == Decimal("10.99")
|
||||||
|
assert me.name == "Twitch"
|
||||||
|
assert me.origin_name == "Twitch"
|
||||||
|
|
||||||
|
service.update_entry(
|
||||||
|
db, entry.id, name="Twitch Prime", amount=Decimal("11.99")
|
||||||
|
)
|
||||||
|
db.refresh(me)
|
||||||
|
assert me.planned == Decimal("10.99")
|
||||||
|
assert me.origin_planned == Decimal("10.99")
|
||||||
|
assert me.name == "Twitch"
|
||||||
|
assert me.origin_name == "Twitch"
|
||||||
|
|
|
||||||
43
tests/test_template_edit_isolation.py
Normal file
43
tests/test_template_edit_isolation.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from quartermaster import month_service, service
|
||||||
|
from quartermaster.models import Section
|
||||||
|
|
||||||
|
|
||||||
|
def test_template_edit_does_not_mutate_existing_month_and_applies_to_next(db):
|
||||||
|
# seed the template
|
||||||
|
twitch = service.add_entry(
|
||||||
|
db, Section.subscription, "Twitch", Decimal("10.99")
|
||||||
|
)
|
||||||
|
|
||||||
|
# create April 2026 (snapshots the current template)
|
||||||
|
april = month_service.create_month(db, "2026-04")
|
||||||
|
april_twitch = next(
|
||||||
|
e for e in april.entries if e.source_entry_id == twitch.id
|
||||||
|
)
|
||||||
|
assert april_twitch.planned == Decimal("10.99")
|
||||||
|
assert april_twitch.origin_planned == Decimal("10.99")
|
||||||
|
assert april_twitch.name == "Twitch"
|
||||||
|
assert april_twitch.origin_name == "Twitch"
|
||||||
|
|
||||||
|
# edit the template
|
||||||
|
service.update_entry(
|
||||||
|
db, twitch.id, name="Twitch Prime", amount=Decimal("11.99")
|
||||||
|
)
|
||||||
|
|
||||||
|
# april is untouched
|
||||||
|
db.refresh(april_twitch)
|
||||||
|
assert april_twitch.planned == Decimal("10.99")
|
||||||
|
assert april_twitch.origin_planned == Decimal("10.99")
|
||||||
|
assert april_twitch.name == "Twitch"
|
||||||
|
assert april_twitch.origin_name == "Twitch"
|
||||||
|
|
||||||
|
# creating May 2026 picks up the new values
|
||||||
|
may = month_service.create_month(db, "2026-05")
|
||||||
|
may_twitch = next(e for e in may.entries if e.source_entry_id == twitch.id)
|
||||||
|
assert may_twitch.planned == Decimal("11.99")
|
||||||
|
assert may_twitch.origin_planned == Decimal("11.99")
|
||||||
|
assert may_twitch.name == "Twitch Prime"
|
||||||
|
assert may_twitch.origin_name == "Twitch Prime"
|
||||||
Loading…
Reference in a new issue