Compare commits
No commits in common. "73825bc30511bb631e4e5adec8d17aec5b36c4a6" and "aa7ebaa234ea857d488306742825129a6a83b87e" have entirely different histories.
73825bc305
...
aa7ebaa234
10 changed files with 242 additions and 1879 deletions
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.db import get_session
|
||||
from quartermaster.models import SECTION_LABELS, Entry, Section
|
||||
from quartermaster.models import SECTION_LABELS, Section
|
||||
|
||||
TEMPLATES_DIR = Path(__file__).parent / "templates"
|
||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||
|
|
@ -39,16 +39,11 @@ def _section_view(db: Session, section: Section) -> service.SectionView:
|
|||
|
||||
|
||||
def _render_section(
|
||||
request: Request,
|
||||
db: Session,
|
||||
section: Section,
|
||||
editing_id: int | None = None,
|
||||
request: Request, db: Session, section: Section
|
||||
) -> HTMLResponse:
|
||||
view = _section_view(db, section)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/section.html",
|
||||
{"section": view, "editing_id": editing_id},
|
||||
request, "partials/section.html", {"section": view}
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -102,7 +97,6 @@ def index(request: Request, db: Session = Depends(get_session)) -> HTMLResponse:
|
|||
"all_months": month_service.list_months(db),
|
||||
"zero": zero,
|
||||
"tone": service.zero_tone(zero),
|
||||
"editing_id": None,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -150,53 +144,17 @@ def remove_entry(
|
|||
return _append_oob(response, *extras)
|
||||
|
||||
|
||||
@router.get("/entries/{entry_id}/edit", response_class=HTMLResponse)
|
||||
def edit_entry(
|
||||
@router.post("/entries/{entry_id}/notes", response_class=HTMLResponse)
|
||||
def update_entry_notes(
|
||||
entry_id: int,
|
||||
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),
|
||||
db: Session = Depends(get_session),
|
||||
) -> HTMLResponse:
|
||||
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
|
||||
)
|
||||
updated = service.set_entry_notes(db, entry_id, notes)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail="entry not found")
|
||||
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)
|
||||
return _render_section(request, db, updated.section)
|
||||
|
||||
|
||||
@router.post("/debt-target", response_class=HTMLResponse)
|
||||
|
|
|
|||
|
|
@ -15,8 +15,6 @@ from quartermaster.groups import (
|
|||
)
|
||||
from quartermaster.models import SECTION_LABELS, DebtTarget, Entry, Section
|
||||
|
||||
_NOTES_SENTINEL = object()
|
||||
|
||||
|
||||
def zero_tone(value: Decimal) -> str:
|
||||
if value == 0:
|
||||
|
|
@ -115,23 +113,13 @@ def _clean_notes(raw: str | None) -> str | None:
|
|||
return stripped if stripped else None
|
||||
|
||||
|
||||
def update_entry(
|
||||
db: Session,
|
||||
entry_id: int,
|
||||
*,
|
||||
name: str | None = None,
|
||||
amount: Decimal | None = None,
|
||||
notes: str | None | object = _NOTES_SENTINEL,
|
||||
def set_entry_notes(
|
||||
db: Session, entry_id: int, notes: str | None
|
||||
) -> Entry | None:
|
||||
entry = db.get(Entry, entry_id)
|
||||
if entry is None:
|
||||
return None
|
||||
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]
|
||||
entry.notes = _clean_notes(notes)
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
return entry
|
||||
|
|
|
|||
|
|
@ -361,160 +361,64 @@ details.group[open] > summary .chevron::after { opacity: 0; }
|
|||
.total .applied { color: var(--ink-soft); }
|
||||
.total.empty { color: var(--muted); }
|
||||
|
||||
/* =============== ENTRY LIST (BUDGET TEMPLATE) =============== */
|
||||
/* =============== ENTRY TABLE =============== */
|
||||
|
||||
.budget-entries { display: flex; flex-direction: column; }
|
||||
table.entries {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-family: var(--sans);
|
||||
}
|
||||
table.entries thead { display: none; }
|
||||
|
||||
.entry-row.reading {
|
||||
table.entries tbody tr.entry {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 5.5rem auto;
|
||||
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;
|
||||
border-bottom: 1px dotted var(--rule);
|
||||
position: relative;
|
||||
border-bottom: 1px dotted var(--rule);
|
||||
}
|
||||
.entry-row.reading:hover { background: var(--paper-stripe); }
|
||||
|
||||
.entry-row.reading .entry-name {
|
||||
font-family: var(--sans);
|
||||
font-weight: 500;
|
||||
font-size: 1.02rem;
|
||||
color: var(--ink);
|
||||
letter-spacing: 0.01em;
|
||||
table.entries tbody tr.entry:hover { background: var(--paper-stripe); }
|
||||
table.entries tbody tr.entry td {
|
||||
padding: 0;
|
||||
border: none;
|
||||
vertical-align: baseline;
|
||||
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;
|
||||
|
||||
table.entries tbody tr.entry::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0; right: 0; bottom: -1px;
|
||||
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,
|
||||
table.entries tbody tr.entry[data-deviation="over"]::after {
|
||||
background: var(--accent);
|
||||
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;
|
||||
table.entries tbody tr.entry.state-new_in_month::after {
|
||||
background: var(--indigo);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.entry-row.reading .entry-actions {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
align-items: center;
|
||||
}
|
||||
.entry-row.reading .entry-actions button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
color: var(--rule);
|
||||
opacity: 0;
|
||||
transition: color 0.12s ease, opacity 0.12s ease;
|
||||
}
|
||||
.entry-row.reading:hover .entry-actions button { opacity: 1; }
|
||||
.entry-row.reading .entry-actions button.edit {
|
||||
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);
|
||||
}
|
||||
|
||||
.empty-row {
|
||||
padding: 0.4rem 0.5rem;
|
||||
tr.empty td {
|
||||
padding: 0.4rem 0.5rem !important;
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.add-row {
|
||||
padding: 0.4rem 0.25rem 0.2rem;
|
||||
tr.add-row td {
|
||||
padding: 0.4rem 0.25rem 0.2rem !important;
|
||||
border-bottom: none !important;
|
||||
grid-column: 1 / -1;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Add-entry disclosure: collapsed trigger, expanded form */
|
||||
|
|
@ -551,18 +455,112 @@ details.add-entry > .month-add-form {
|
|||
margin-top: 0.45rem;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.entry-row.reading {
|
||||
grid-template-columns: minmax(0, 1fr) 4.6rem auto;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.entry-row.editing {
|
||||
grid-template-columns: minmax(0, 1fr) 4.6rem;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.entry-row.editing .notes-input,
|
||||
.entry-row.editing .entry-actions { grid-column: 1 / -1; }
|
||||
.entry-name {
|
||||
font-family: var(--sans);
|
||||
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-name input {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
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-name input:focus { border-bottom-color: var(--ink); }
|
||||
|
||||
.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 {
|
||||
font-family: var(--sans);
|
||||
|
|
@ -580,31 +578,6 @@ details.add-entry > .month-add-form {
|
|||
.tag-edited { color: var(--ochre); }
|
||||
.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 =============== */
|
||||
|
||||
.section.target-section {
|
||||
|
|
@ -1007,6 +980,13 @@ form.add-posting-form button[type="submit"] {
|
|||
}
|
||||
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) {
|
||||
.entry-block > summary {
|
||||
grid-template-columns: 0.9rem minmax(0, 1fr) 4.2rem 7rem 1rem;
|
||||
|
|
@ -1138,4 +1118,8 @@ button[disabled] {
|
|||
text-align: center;
|
||||
justify-self: center;
|
||||
}
|
||||
table.entries tbody tr.entry {
|
||||
grid-template-columns: minmax(0, 1fr) 4.6rem 4.6rem 1rem;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,68 +5,14 @@
|
|||
${{ '{:,.2f}'.format(section.total) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="entries budget-entries">
|
||||
{% for entry in section.entries %}
|
||||
{% if editing_id is not none and entry.id == editing_id %}
|
||||
<form
|
||||
class="entry-row editing"
|
||||
hx-post="/entries/{{ entry.id }}"
|
||||
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>
|
||||
<table class="entries">
|
||||
<tbody>
|
||||
{% for entry in section.entries %}
|
||||
<tr class="entry state-unchanged" style="--ratio: 1">
|
||||
<td class="entry-name">{{ entry.name }}</td>
|
||||
<td class="entry-amount">${{ '{:,.2f}'.format(entry.amount) }}</td>
|
||||
<td class="entry-amount"></td>
|
||||
<td class="entry-actions">
|
||||
<button
|
||||
class="delete"
|
||||
type="button"
|
||||
|
|
@ -75,28 +21,46 @@
|
|||
hx-swap="outerHTML"
|
||||
aria-label="Delete {{ entry.name }}"
|
||||
>×</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="empty-row">No entries yet.</div>
|
||||
{% endfor %}
|
||||
<div class="add-row">
|
||||
<details class="add-entry">
|
||||
<summary><span class="add-trigger">+ add {{ section.label|lower }}</span></summary>
|
||||
<form
|
||||
class="add-form"
|
||||
hx-post="/sections/{{ section.section.value }}/entries"
|
||||
hx-target="#section-{{ section.section.value }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-on::after-request="if(event.detail.successful) this.reset()"
|
||||
>
|
||||
<input type="text" name="name" placeholder="Name" required>
|
||||
<input type="number" name="amount" step="0.01" min="0" placeholder="0.00" required>
|
||||
<button type="submit">Add</button>
|
||||
<input class="notes-input add-notes" type="text" name="notes" placeholder="notes (optional)">
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="entry-notes-row">
|
||||
<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 %}
|
||||
<tr class="empty"><td colspan="4">No entries yet.</td></tr>
|
||||
{% endfor %}
|
||||
<tr class="add-row">
|
||||
<td colspan="4">
|
||||
<details class="add-entry">
|
||||
<summary><span class="add-trigger">+ add {{ section.label|lower }}</span></summary>
|
||||
<form
|
||||
class="add-form"
|
||||
hx-post="/sections/{{ section.section.value }}/entries"
|
||||
hx-target="#section-{{ section.section.value }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-on::after-request="if(event.detail.successful) this.reset()"
|
||||
>
|
||||
<input type="text" name="name" placeholder="Name" required>
|
||||
<input type="number" name="amount" step="0.01" min="0" placeholder="0.00" required>
|
||||
<button type="submit">Add</button>
|
||||
<input class="notes-input add-notes" type="text" name="notes" placeholder="notes (optional)">
|
||||
</form>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
</div>
|
||||
<table class="entries">
|
||||
<tbody>
|
||||
<tr class="entry">
|
||||
<tr class="entry" style="--ratio: 1">
|
||||
<td class="entry-name">
|
||||
{% if target.entry %}{{ target.entry.name }}{% else %}<span class="muted">No target selected.</span>{% endif %}
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,16 @@ def test_add_entry_strips_and_nulls_empty_notes(db):
|
|||
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):
|
||||
service.add_entry(
|
||||
|
|
@ -81,39 +91,26 @@ def test_create_entry_route_accepts_notes(client):
|
|||
assert "3 mo cushion" in response.text
|
||||
|
||||
|
||||
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):
|
||||
def test_update_entry_notes_route(client):
|
||||
client.post(
|
||||
"/sections/food/entries",
|
||||
data={"name": "Groceries", "amount": "400.00"},
|
||||
)
|
||||
response = client.post("/entries/1/notes", data={"notes": "weekly"})
|
||||
assert response.status_code == 404
|
||||
assert response.status_code == 200
|
||||
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):
|
||||
|
|
@ -139,27 +136,17 @@ def test_update_month_entry_route_accepts_notes(client):
|
|||
assert "auto-pay" in response.text
|
||||
|
||||
|
||||
def test_budget_page_renders_note_badge_when_notes_set(client):
|
||||
def test_budget_page_renders_notes_inputs(client):
|
||||
client.post(
|
||||
"/sections/fixed_bill/entries",
|
||||
data={"name": "Rent", "amount": "1200.00", "notes": "due 1st"},
|
||||
)
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "note-badge" in response.text
|
||||
assert "entry-notes-row" 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):
|
||||
client.post(
|
||||
"/sections/fixed_bill/entries",
|
||||
|
|
|
|||
|
|
@ -86,142 +86,3 @@ def test_reject_non_debt_minimum_target(client):
|
|||
)
|
||||
response = client.post("/debt-target", data={"debt_minimum_id": "1"})
|
||||
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,83 +41,3 @@ def test_debt_target_cleared_on_delete(db):
|
|||
service.delete_entry(db, dm.id)
|
||||
target = service.get_debt_target(db)
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
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