feat: Tabellen per Spaltenkopf sortierbar (A11-T4)

Neues static/sort.js sortiert Tabellen clientseitig per Klick auf den
Spaltenkopf (⇅/▲/▼): Datum TT.MM.JJJJ, deutsche Zahlen/Beträge, sonst
Text (Intl.Collator 'de'). Sortierwert optional per td[data-sort]
(Punkt-Dezimal/ISO) überschreibbar, th[data-nosort] bekommt keinen
Button, table[data-sort-mode="server"] wird übersprungen (Buchungen
sortiert serverseitig, folgt in Task 5). Bearbeiten-/Tilgungsplan-
Zeilen (tr[data-sort-follow]) wandern mit ihrer Vorzeile mit.
Initialisierung über htmx.onLoad, greift auch bei nachgeladenen
Fragmenten.

Templates entsprechend annotiert (Planung, Szenarien, Admin, Index,
Salden, Import-Liste, Buchungen); Salden-Gesamtzeile dafür in ein
tfoot verschoben. test_gui.py::test_gui_zeigt_deutsche_betragsformate
angepasst, da das neue machinenlesbare data-sort-Attribut bewusst
Punkt-Dezimal führt (Interface-Konvention) - die Prüfung auf deutsches
Format im sichtbaren Text bleibt bestehen.

JS-Unit-/DOM-Tests unter tests/js (node --test, DOM-Teil optional via
jsdom), pytest-Wrapper skip ohne node (Fable-Gate führt sie per
Podman aus). Suite: 248 passed, 3 skipped (vorher 242/2).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 19:22:34 +02:00
co-authored by Claude Opus 5.5
parent 42b410446d
commit 4458300f43
15 changed files with 399 additions and 20 deletions
+60
View File
@@ -0,0 +1,60 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
let JSDOM = null;
try { ({ JSDOM } = require('jsdom')); } catch (e) { /* jsdom fehlt -> skip */ }
const SRC = fs.readFileSync(path.join(__dirname, '../../app/static/sort.js'), 'utf8');
function load(html) {
const dom = new JSDOM(`<!doctype html><body>${html}</body>`, { runScripts: 'outside-only' });
dom.window.eval(SRC);
dom.window.FBSort.init(dom.window.document.body);
return dom.window.document;
}
const TABLE = `
<table id="t"><thead><tr><th>Name</th><th>Betrag</th><th>Aktion</th><th></th></tr></thead>
<tbody>
<tr id="r1"><td>B</td><td>10,00 €</td><td>x</td><td></td></tr>
<tr id="r1-edit" hidden data-sort-follow><td colspan="4">edit1</td></tr>
<tr id="r2"><td>A</td><td>-5,00 €</td><td>x</td><td></td></tr>
<tr id="r2-edit" hidden data-sort-follow><td colspan="4">edit2</td></tr>
<tr id="r3"><td>C</td><td>–</td><td>x</td><td></td></tr>
</tbody>
<tfoot><tr id="sum"><td>Gesamt</td><td>5,00 €</td><td></td><td></td></tr></tfoot>
</table>
<table data-sort-mode="server" id="s"><thead><tr><th>Datum</th></tr></thead><tbody></tbody></table>`;
const ids = (doc) => [...doc.querySelectorAll('#t tbody tr')].map((r) => r.id);
test('Buttons nur an Spalten mit Text, nicht an server-Tabellen', { skip: !JSDOM }, () => {
const doc = load(TABLE.replace('<th>Aktion</th>', '<th data-nosort>Aktion</th>'));
assert.equal(doc.querySelectorAll('#t .sort-btn').length, 2);
assert.equal(doc.querySelectorAll('#s .sort-btn').length, 0);
assert.equal(doc.querySelector('#t .sort-btn').textContent, '⇅');
});
test('Klick sortiert auf/ab, Folgezeilen wandern mit, Leerwert am Ende, tfoot bleibt', { skip: !JSDOM }, () => {
const doc = load(TABLE);
const btn = doc.querySelectorAll('#t .sort-btn')[1]; // Betrag
btn.click();
assert.equal(btn.textContent, '▲');
assert.deepEqual(ids(doc), ['r2', 'r2-edit', 'r1', 'r1-edit', 'r3']);
btn.click();
assert.equal(btn.textContent, '▼');
assert.deepEqual(ids(doc), ['r1', 'r1-edit', 'r2', 'r2-edit', 'r3']);
assert.equal(doc.querySelector('#t tfoot tr').id, 'sum');
const nameBtn = doc.querySelectorAll('#t .sort-btn')[0];
nameBtn.click();
assert.equal(btn.textContent, '⇅');
assert.deepEqual(ids(doc), ['r2', 'r2-edit', 'r1', 'r1-edit', 'r3']);
});
test('init zweimal erzeugt keine doppelten Buttons', { skip: !JSDOM }, () => {
const doc = load(TABLE);
doc.defaultView.FBSort.init(doc.body);
assert.equal(doc.querySelectorAll('#t .sort-btn').length, 3);
});
+72
View File
@@ -0,0 +1,72 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const S = require('../../app/static/sort.js');
const items = (vals, ds) => vals.map((v, i) => ({ key: S.sortKey(v, !!ds), index: i, label: v }));
const labels = (arr) => arr.map((i) => i.label);
test('Datum TT.MM.JJJJ', () => {
const k = S.sortKey('05.03.2026', false);
assert.equal(k.kind, 'date');
assert.equal(k.value, 20260305);
});
test('deutsche Beträge und Zahlen', () => {
assert.equal(S.sortKey('-1.234,56 €', false).value, -1234.56);
assert.equal(S.sortKey('5,00 %', false).value, 5);
assert.equal(S.sortKey('15', false).kind, 'number');
assert.equal(S.sortKey('1.234', false).value, 1234);
});
test('data-sort: Punkt-Dezimal und ISO-Datum', () => {
assert.equal(S.sortKey('-1234.56', true).value, -1234.56);
assert.equal(S.sortKey('2026-06-30', true).kind, 'text');
});
test('Leerwerte sind null', () => {
assert.equal(S.sortKey('', false), null);
assert.equal(S.sortKey(' ', false), null);
assert.equal(S.sortKey('–', false), null);
assert.equal(S.sortKey(undefined, false), null);
});
test('Zahlen numerisch statt alphabetisch', () => {
assert.deepEqual(labels(S.sortItems(items(['10,00', '9,00', '-100,00']), 'asc')),
['-100,00', '9,00', '10,00']);
assert.deepEqual(labels(S.sortItems(items(['10,00', '9,00', '-100,00']), 'desc')),
['10,00', '9,00', '-100,00']);
});
test('Datum chronologisch', () => {
assert.deepEqual(labels(S.sortItems(items(['01.02.2026', '15.01.2026', '31.12.2025']), 'asc')),
['31.12.2025', '15.01.2026', '01.02.2026']);
});
test('Leerwerte immer am Ende, beide Richtungen', () => {
const v = ['01.02.2026', '–', '15.01.2026', ''];
assert.deepEqual(labels(S.sortItems(items(v), 'asc')), ['15.01.2026', '01.02.2026', '–', '']);
assert.deepEqual(labels(S.sortItems(items(v), 'desc')), ['01.02.2026', '15.01.2026', '–', '']);
});
test('gemischte Typen -> Text', () => {
assert.equal(S.columnKind([S.sortKey('10,00'), S.sortKey('abc'), null]), 'text');
assert.equal(S.columnKind([S.sortKey('10,00'), null]), 'number');
});
test('Text deutsch (Umlaute, Groß/klein)', () => {
assert.deepEqual(labels(S.sortItems(items(['Zahnarzt', 'Ärztin', 'miete']), 'asc')),
['Ärztin', 'miete', 'Zahnarzt']);
});
test('stabil bei gleichen Werten', () => {
const it = [{ key: S.sortKey('5'), index: 0, label: 'a' }, { key: S.sortKey('5'), index: 1, label: 'b' }];
assert.deepEqual(labels(S.sortItems(it, 'desc')), ['a', 'b']);
});
test('groupRows: Folgezeilen hängen an der Vorzeile', () => {
const rows = [{ id: 'a' }, { id: 'a-edit', f: true }, { id: 'a-plan', f: true },
{ id: 'b' }, { id: 'b-edit', f: true }];
assert.deepEqual(S.groupRows(rows, (r) => !!r.f).map((g) => g.map((r) => r.id)),
[['a', 'a-edit', 'a-plan'], ['b', 'b-edit']]);
});
+5 -1
View File
@@ -1,3 +1,4 @@
import re
from datetime import date, timedelta
from decimal import Decimal
@@ -55,7 +56,10 @@ def test_gui_zeigt_deutsche_betragsformate(client, db):
db.commit()
r = client.get("/")
assert "12.345,60" in r.text
assert "12345.60" not in r.text
# Punkt-Dezimal darf nur im maschinenlesbaren data-sort-Attribut
# (Ausbaustufe 11, static/sort.js) stehen, nicht im sichtbaren Text.
visible = re.sub(r' data-sort="[^"]*"', '', r.text)
assert "12345.60" not in visible
def test_planung_zeigt_deutsche_labels(client, db):
+18
View File
@@ -0,0 +1,18 @@
"""Führt die JS-Tests von static/sort.js aus, falls `node` installiert ist.
Auf dem Host fehlt node derzeit - dann skip; das Fable-Gate führt die Tests
per Podman aus (siehe Plan A11 Task 4, Fable-Gate)."""
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
NODE = shutil.which("node")
@pytest.mark.skipif(NODE is None, reason="node nicht installiert")
def test_sort_js():
r = subprocess.run([NODE, "--test", str(ROOT / "tests" / "js")],
capture_output=True, text=True)
assert r.returncode == 0, r.stdout + r.stderr
+72
View File
@@ -0,0 +1,72 @@
import re
from datetime import date
from decimal import Decimal
from app.models.tables import (Account, Category, Loan, PlannedItem,
RecurringItem, Scenario, ScenarioModifier,
ScenarioPlannedItem)
def _login(client):
client.post("/login", data={"username": "admin", "password": "geheim"})
def _follow_ids(html):
return re.findall(r'<tr id="([a-z]+-(?:edit|plan)-\d+)"[^>]*data-sort-follow', html)
def test_base_bindet_sort_js_ein(client):
_login(client)
assert '<script src="/static/sort.js"></script>' in client.get("/").text
def test_planung_folgezeilen_markiert(client, db):
_login(client)
c = Category(name="K")
db.add(c)
db.flush()
db.add_all([
RecurringItem(name="R", amount=Decimal("-1.00"), rhythm="monthly", due_day=1, category_id=c.id),
PlannedItem(name="P", amount=Decimal("-1.00"), due=date(2030, 1, 1)),
Loan(name="L", principal=Decimal("100.00"), annual_rate_pct=Decimal("1"),
term_months=2, payout_date=date(2030, 1, 1), repayment_type="annuity"),
])
db.commit()
kinds = sorted(i.split("-")[0] + "-" + i.split("-")[1] for i in _follow_ids(client.get("/planung").text))
assert kinds == ["loan-edit", "loan-plan", "pln-edit", "rec-edit"]
def test_szenarien_und_admin_folgezeilen_markiert(client, db):
_login(client)
c = Category(name="K")
sc = Scenario(name="S", description="")
db.add_all([c, sc])
db.flush()
db.add_all([
ScenarioModifier(scenario_id=sc.id, target_type="category", target_id=c.id,
kind="percent", value=Decimal("10")),
ScenarioPlannedItem(scenario_id=sc.id, name="E", amount=Decimal("-1.00"), due=date(2030, 1, 1)),
])
db.commit()
assert sorted(i.rsplit("-", 1)[0] for i in _follow_ids(client.get("/szenarien").text)) == ["mod-edit", "spi-edit"]
assert [i.rsplit("-", 1)[0] for i in _follow_ids(client.get("/admin").text)] == ["cat-edit"]
def test_salden_gesamtzeile_im_tfoot(client, db):
_login(client)
db.add(Account(bank="t", iban="DE-S-1", name="Konto", type="giro"))
db.commit()
html = client.get("/salden").text
assert re.search(r'<tfoot>\s*<tr class="total-row">', html)
def test_index_aktionsspalten_ohne_sortierung(client):
_login(client)
html = client.get("/").text
assert "<th data-nosort>Umbenennen</th>" in html
assert "<th data-nosort>Anker pflegen</th>" in html
def test_buchungen_tabelle_ist_serverseitig(client):
_login(client)
assert 'data-sort-mode="server"' in client.get("/buchungen").text