diff --git a/app.py b/app.py
index 49686d0..9f3652f 100644
--- a/app.py
+++ b/app.py
@@ -253,6 +253,141 @@ def fees_juniors():
payments_url=payments_url
)
+@app.route("/adults")
+def adults_view():
+ attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
+ payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
+ credentials_path = CREDENTIALS_PATH
+
+ members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
+ record_step("fetch_members")
+ if not members_data:
+ return "No data."
+ members, sorted_months = members_data
+
+ transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
+ record_step("fetch_payments")
+ exceptions = get_cached_data(
+ "exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
+ PAYMENTS_SHEET_ID, credentials_path,
+ serialize=lambda d: [[list(k), v] for k, v in d.items()],
+ deserialize=lambda c: {tuple(k): v for k, v in c},
+ )
+ record_step("fetch_exceptions")
+ result = reconcile(members, sorted_months, transactions, exceptions)
+ record_step("reconcile")
+
+ month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS)
+ adult_names = sorted([name for name, tier, _ in members if tier == "A"])
+
+ monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months}
+ formatted_results = []
+ for name in adult_names:
+ data = result["members"][name]
+ row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": ""}
+ unpaid_months = []
+ for m in sorted_months:
+ mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None})
+ expected = mdata.get("expected", 0)
+ original_expected = mdata.get("original_expected", 0)
+ count = mdata.get("attendance_count", 0)
+ paid = int(mdata.get("paid", 0))
+ exception_info = mdata.get("exception", None)
+
+ monthly_totals[m]["expected"] += expected
+ monthly_totals[m]["paid"] += paid
+
+ override_amount = exception_info["amount"] if exception_info else None
+
+ if override_amount is not None and override_amount != original_expected:
+ is_overridden = True
+ fee_display = f"{override_amount} ({original_expected}) CZK ({count})" if count > 0 else f"{override_amount} ({original_expected}) CZK"
+ else:
+ is_overridden = False
+ fee_display = f"{expected} CZK ({count})" if count > 0 else f"{expected} CZK"
+
+ status = "empty"
+ cell_text = "-"
+ amount_to_pay = 0
+
+ if expected > 0:
+ amount_to_pay = max(0, expected - paid)
+ if paid >= expected:
+ status = "ok"
+ cell_text = f"{paid}/{fee_display}"
+ elif paid > 0:
+ status = "partial"
+ cell_text = f"{paid}/{fee_display}"
+ unpaid_months.append(month_labels[m])
+ else:
+ status = "unpaid"
+ cell_text = f"0/{fee_display}"
+ unpaid_months.append(month_labels[m])
+ elif paid > 0:
+ status = "surplus"
+ cell_text = f"PAID {paid}"
+ else:
+ cell_text = "-"
+ amount_to_pay = 0
+
+ if expected > 0 or paid > 0:
+ tooltip = f"Received: {paid}, Expected: {expected}"
+ else:
+ tooltip = ""
+
+ row["months"].append({
+ "text": cell_text,
+ "overridden": is_overridden,
+ "status": status,
+ "amount": amount_to_pay,
+ "month": month_labels[m],
+ "tooltip": tooltip
+ })
+
+ row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "")
+ row["balance"] = data["total_balance"]
+ formatted_results.append(row)
+
+ formatted_totals = []
+ for m in sorted_months:
+ t = monthly_totals[m]
+ status = "empty"
+ if t["expected"] > 0 or t["paid"] > 0:
+ if t["paid"] == t["expected"]:
+ status = "ok"
+ elif t["paid"] < t["expected"]:
+ status = "unpaid"
+ else:
+ status = "surplus"
+
+ formatted_totals.append({
+ "text": f"{t['paid']} / {t['expected']} CZK",
+ "status": status
+ })
+
+ credits = sorted([{"name": n, "amount": a["total_balance"]} for n, a in result["members"].items() if a["total_balance"] > 0 and n in adult_names], key=lambda x: x["name"])
+ debts = sorted([{"name": n, "amount": abs(a["total_balance"])} for n, a in result["members"].items() if a["total_balance"] < 0 and n in adult_names], key=lambda x: x["name"])
+ unmatched = result["unmatched"]
+ import json
+
+ record_step("process_data")
+
+ return render_template(
+ "adults.html",
+ months=[month_labels[m] for m in sorted_months],
+ raw_months=sorted_months,
+ results=formatted_results,
+ totals=formatted_totals,
+ member_data=json.dumps(result["members"]),
+ month_labels_json=json.dumps(month_labels),
+ credits=credits,
+ debts=debts,
+ unmatched=unmatched,
+ attendance_url=attendance_url,
+ payments_url=payments_url,
+ bank_account=BANK_ACCOUNT
+ )
+
@app.route("/reconcile")
def reconcile_view():
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
@@ -352,6 +487,178 @@ def reconcile_view():
bank_account=BANK_ACCOUNT
)
+@app.route("/juniors")
+def juniors_view():
+ attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}"
+ payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
+
+ credentials_path = CREDENTIALS_PATH
+
+ junior_members_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
+ record_step("fetch_junior_members")
+ if not junior_members_data:
+ return "No data."
+ junior_members, sorted_months = junior_members_data
+
+ transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
+ record_step("fetch_payments")
+ exceptions = get_cached_data(
+ "exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
+ PAYMENTS_SHEET_ID, credentials_path,
+ serialize=lambda d: [[list(k), v] for k, v in d.items()],
+ deserialize=lambda c: {tuple(k): v for k, v in c},
+ )
+ record_step("fetch_exceptions")
+
+ # Adapt junior tuple format (name, tier, {month: (fee, total_count, adult_count, junior_count)})
+ # to what match_payments expects: (name, tier, {month: (expected_fee, attendance_count)})
+ adapted_members = []
+ for name, tier, fees_dict in junior_members:
+ adapted_fees = {}
+ for m, fee_data in fees_dict.items():
+ if len(fee_data) == 4:
+ fee, total_count, _, _ = fee_data
+ adapted_fees[m] = (fee, total_count)
+ else:
+ fee, count = fee_data
+ adapted_fees[m] = (fee, count)
+ adapted_members.append((name, tier, adapted_fees))
+
+ result = reconcile(adapted_members, sorted_months, transactions, exceptions)
+ record_step("reconcile")
+
+ # Format month labels
+ month_labels = get_month_labels(sorted_months, JUNIOR_MERGED_MONTHS)
+ junior_names = sorted([name for name, tier, _ in adapted_members])
+ junior_members_dict = {name: fees_dict for name, _, fees_dict in junior_members}
+
+ monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months}
+ formatted_results = []
+ for name in junior_names:
+ data = result["members"][name]
+ row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": ""}
+ unpaid_months = []
+ for m in sorted_months:
+ mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None})
+ expected = mdata.get("expected", 0)
+ original_expected = mdata.get("original_expected", 0)
+ count = mdata.get("attendance_count", 0)
+ paid = int(mdata.get("paid", 0))
+ exception_info = mdata.get("exception", None)
+
+ if expected != "?" and isinstance(expected, int):
+ monthly_totals[m]["expected"] += expected
+ monthly_totals[m]["paid"] += paid
+
+ orig_fee_data = junior_members_dict.get(name, {}).get(m)
+ adult_count = 0
+ junior_count = 0
+ if orig_fee_data and len(orig_fee_data) == 4:
+ _, _, adult_count, junior_count = orig_fee_data
+
+ breakdown = ""
+ if adult_count > 0 and junior_count > 0:
+ breakdown = f":{junior_count}J,{adult_count}A"
+ elif junior_count > 0:
+ breakdown = f":{junior_count}J"
+ elif adult_count > 0:
+ breakdown = f":{adult_count}A"
+
+ count_str = f" ({count}{breakdown})" if count > 0 else ""
+
+ override_amount = exception_info["amount"] if exception_info else None
+
+ if override_amount is not None and override_amount != original_expected:
+ is_overridden = True
+ fee_display = f"{override_amount} ({original_expected}) CZK{count_str}"
+ else:
+ is_overridden = False
+ fee_display = f"{expected} CZK{count_str}"
+
+ status = "empty"
+ cell_text = "-"
+ amount_to_pay = 0
+
+ if expected == "?" or (isinstance(expected, int) and expected > 0):
+ if expected == "?":
+ status = "empty"
+ cell_text = "?"
+ elif paid >= expected:
+ status = "ok"
+ cell_text = f"{paid}/{fee_display}"
+ elif paid > 0:
+ status = "partial"
+ cell_text = f"{paid}/{fee_display}"
+ amount_to_pay = expected - paid
+ unpaid_months.append(month_labels[m])
+ else:
+ status = "unpaid"
+ cell_text = f"0/{fee_display}"
+ amount_to_pay = expected
+ unpaid_months.append(month_labels[m])
+ elif paid > 0:
+ status = "surplus"
+ cell_text = f"PAID {paid}"
+
+ if (isinstance(expected, int) and expected > 0) or paid > 0:
+ tooltip = f"Received: {paid}, Expected: {expected}"
+ else:
+ tooltip = ""
+
+ row["months"].append({
+ "text": cell_text,
+ "overridden": is_overridden,
+ "status": status,
+ "amount": amount_to_pay,
+ "month": month_labels[m],
+ "tooltip": tooltip
+ })
+
+ row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "")
+ row["balance"] = data["total_balance"]
+ formatted_results.append(row)
+
+ formatted_totals = []
+ for m in sorted_months:
+ t = monthly_totals[m]
+ status = "empty"
+ if t["expected"] > 0 or t["paid"] > 0:
+ if t["paid"] == t["expected"]:
+ status = "ok"
+ elif t["paid"] < t["expected"]:
+ status = "unpaid"
+ else:
+ status = "surplus"
+
+ formatted_totals.append({
+ "text": f"{t['paid']} / {t['expected']} CZK",
+ "status": status
+ })
+
+ # Format credits and debts
+ credits = sorted([{"name": n, "amount": a["total_balance"]} for n, a in result["members"].items() if a["total_balance"] > 0], key=lambda x: x["name"])
+ debts = sorted([{"name": n, "amount": abs(a["total_balance"])} for n, a in result["members"].items() if a["total_balance"] < 0], key=lambda x: x["name"])
+ unmatched = result["unmatched"]
+ import json
+
+ record_step("process_data")
+
+ return render_template(
+ "juniors.html",
+ months=[month_labels[m] for m in sorted_months],
+ raw_months=sorted_months,
+ results=formatted_results,
+ totals=formatted_totals,
+ member_data=json.dumps(result["members"]),
+ month_labels_json=json.dumps(month_labels),
+ credits=credits,
+ debts=debts,
+ unmatched=unmatched,
+ attendance_url=attendance_url,
+ payments_url=payments_url,
+ bank_account=BANK_ACCOUNT
+ )
+
@app.route("/reconcile-juniors")
def reconcile_juniors_view():
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}"
diff --git a/templates/adults.html b/templates/adults.html
new file mode 100644
index 0000000..f48eae0
--- /dev/null
+++ b/templates/adults.html
@@ -0,0 +1,891 @@
+
+
+
+
+
+
+ FUJ Adults Dashboard
+
+
+
+
+
+
+ Adults Dashboard
+
+
+
+
+ search member:
+
+
+
+
+
+
+
+ | Member |
+ {% for m in months %}
+ {{ m }} |
+ {% endfor %}
+ Balance |
+
+
+
+ {% for row in results %}
+
+ |
+ {{ row.name }}
+ [i]
+ |
+ {% for cell in row.months %}
+
+ {{ cell.text }}
+ {% if cell.status == 'unpaid' or cell.status == 'partial' %}
+
+ {% endif %}
+ |
+ {% endfor %}
+
+ {{ "%+d"|format(row.balance) if row.balance != 0 else "0" }}
+ {% if row.balance < 0 %}
+
+ {% endif %}
+ |
+
+ {% endfor %}
+
+ |
+ TOTAL
+ |
+ {% for t in totals %}
+
+ received / expected
+ {{ t.text }}
+ |
+ {% endfor %}
+ |
+
+
+
+
+
+ {% if credits %}
+ Credits (Advance Payments / Surplus)
+
+ {% for item in credits %}
+
+ {{ item.name }}
+ {{ item.amount }} CZK
+
+ {% endfor %}
+
+ {% endif %}
+
+ {% if debts %}
+ Debts (Missing Payments)
+
+ {% for item in debts %}
+
+ {{ item.name }}
+ {{ item.amount }} CZK
+
+ {% endfor %}
+
+ {% endif %}
+
+ {% if unmatched %}
+ Unmatched Transactions
+
+
+ {% for tx in unmatched %}
+
+ {{ tx.date }}
+ {{ tx.amount }}
+ {{ tx.sender }}
+ {{ tx.message }}
+
+ {% endfor %}
+
+ {% endif %}
+
+
+
+
+
+
+
![Payment QR Code]()
+
+
+
Account:
+
Amount: CZK
+
Message:
+
+
+
+
+
+
+
+
+
+
Status Summary
+
Tier: -
+
+
+
+ | Month |
+ Att. |
+ Expected |
+ Paid |
+ Status |
+
+
+
+
+
+
+
+
+
+
+
+
Other Transactions
+
+
+
+
+
+
+
Payment History
+
+
+
+
+
+
+
+ {% set rt = get_render_time() %}
+
+
+
+
+
+
+```
\ No newline at end of file
diff --git a/templates/fees-juniors.html b/templates/fees-juniors.html
index 85b7a6b..3368413 100644
--- a/templates/fees-juniors.html
+++ b/templates/fees-juniors.html
@@ -96,8 +96,16 @@
margin-bottom: 20px;
font-size: 12px;
color: #555;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ align-items: center;
+ }
+
+ .nav > div {
display: flex;
gap: 15px;
+ align-items: center;
}
.nav a {
@@ -118,6 +126,23 @@
border-color: #555;
}
+ .nav-archived a {
+ font-size: 10px;
+ color: #666;
+ border-color: #222;
+ }
+
+ .nav-archived a.active {
+ color: #ccc;
+ background-color: #333;
+ border-color: #555;
+ }
+
+ .nav-archived a:hover {
+ color: #999;
+ border-color: #444;
+ }
+
.description {
margin-bottom: 20px;
text-align: center;
@@ -155,11 +180,18 @@
FUJ Junior Fees Dashboard
diff --git a/templates/fees.html b/templates/fees.html
index 5e65d1d..d265817 100644
--- a/templates/fees.html
+++ b/templates/fees.html
@@ -111,8 +111,16 @@
margin-bottom: 20px;
font-size: 12px;
color: #555;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ align-items: center;
+ }
+
+ .nav > div {
display: flex;
gap: 15px;
+ align-items: center;
}
.nav a {
@@ -133,6 +141,23 @@
border-color: #555;
}
+ .nav-archived a {
+ font-size: 10px;
+ color: #666;
+ border-color: #222;
+ }
+
+ .nav-archived a.active {
+ color: #ccc;
+ background-color: #333;
+ border-color: #555;
+ }
+
+ .nav-archived a:hover {
+ color: #999;
+ border-color: #444;
+ }
+
.description {
margin-bottom: 20px;
text-align: center;
@@ -170,11 +195,18 @@
FUJ Fees Dashboard
diff --git a/templates/juniors.html b/templates/juniors.html
new file mode 100644
index 0000000..76fe26b
--- /dev/null
+++ b/templates/juniors.html
@@ -0,0 +1,872 @@
+
+
+
+
+
+
+ FUJ Juniors Dashboard
+
+
+
+
+
+
+ Juniors Dashboard
+
+
+
+
+ search member:
+
+
+
+
+
+
+
+ | Member |
+ {% for m in months %}
+ {{ m }} |
+ {% endfor %}
+ Balance |
+
+
+
+ {% for row in results %}
+
+ |
+ {{ row.name }}
+ [i]
+ |
+ {% for cell in row.months %}
+
+ {{ cell.text }}
+ {% if cell.status == 'unpaid' or cell.status == 'partial' %}
+
+ {% endif %}
+ |
+ {% endfor %}
+
+ {{ "%+d"|format(row.balance) if row.balance != 0 else "0" }}
+ {% if row.balance < 0 %}
+
+ {% endif %}
+ |
+
+ {% endfor %}
+
+ |
+ TOTAL
+ |
+ {% for t in totals %}
+
+ received / expected
+ {{ t.text }}
+ |
+ {% endfor %}
+ |
+
+
+
+
+
+ {% if credits %}
+ Credits (Advance Payments / Surplus)
+
+ {% for item in credits %}
+
+ {{ item.name }}
+ {{ item.amount }} CZK
+
+ {% endfor %}
+
+ {% endif %}
+
+ {% if debts %}
+ Debts (Missing Payments)
+
+ {% for item in debts %}
+
+ {{ item.name }}
+ {{ item.amount }} CZK
+
+ {% endfor %}
+
+ {% endif %}
+
+
+
+
+
+
+
+
![Payment QR Code]()
+
+
+
Account:
+
Amount: CZK
+
Message:
+
+
+
+
+
+
+
+
+
+
Status Summary
+
Tier: -
+
+
+
+ | Month |
+ Att. |
+ Expected |
+ Paid |
+ Status |
+
+
+
+
+
+
+
+
+
+
+
+
Other Transactions
+
+
+
+
+
+
+
Payment History
+
+
+
+
+
+
+
+ {% set rt = get_render_time() %}
+
+
+
+
+
+
+```
\ No newline at end of file
diff --git a/templates/payments.html b/templates/payments.html
index 161b911..ca8ec89 100644
--- a/templates/payments.html
+++ b/templates/payments.html
@@ -45,8 +45,16 @@
margin-bottom: 20px;
font-size: 12px;
color: #555;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ align-items: center;
+ }
+
+ .nav > div {
display: flex;
gap: 15px;
+ align-items: center;
}
.nav a {
@@ -67,6 +75,23 @@
border-color: #555;
}
+ .nav-archived a {
+ font-size: 10px;
+ color: #666;
+ border-color: #222;
+ }
+
+ .nav-archived a.active {
+ color: #ccc;
+ background-color: #333;
+ border-color: #555;
+ }
+
+ .nav-archived a:hover {
+ color: #999;
+ border-color: #444;
+ }
+
.description {
margin-bottom: 20px;
text-align: center;
@@ -159,11 +184,18 @@
Payments Ledger
diff --git a/templates/reconcile-juniors.html b/templates/reconcile-juniors.html
index a5d7086..177e2cd 100644
--- a/templates/reconcile-juniors.html
+++ b/templates/reconcile-juniors.html
@@ -45,8 +45,16 @@
margin-bottom: 20px;
font-size: 12px;
color: #555;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ align-items: center;
+ }
+
+ .nav > div {
display: flex;
gap: 15px;
+ align-items: center;
}
.nav a {
@@ -67,6 +75,23 @@
border-color: #555;
}
+ .nav-archived a {
+ font-size: 10px;
+ color: #666;
+ border-color: #222;
+ }
+
+ .nav-archived a.active {
+ color: #ccc;
+ background-color: #333;
+ border-color: #555;
+ }
+
+ .nav-archived a:hover {
+ color: #999;
+ border-color: #444;
+ }
+
.description {
margin-bottom: 20px;
text-align: center;
@@ -423,11 +448,18 @@
Junior Payment Reconciliation
diff --git a/templates/reconcile.html b/templates/reconcile.html
index aad9de4..74abbe6 100644
--- a/templates/reconcile.html
+++ b/templates/reconcile.html
@@ -45,8 +45,16 @@
margin-bottom: 20px;
font-size: 12px;
color: #555;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ align-items: center;
+ }
+
+ .nav > div {
display: flex;
gap: 15px;
+ align-items: center;
}
.nav a {
@@ -67,6 +75,23 @@
border-color: #555;
}
+ .nav-archived a {
+ font-size: 10px;
+ color: #666;
+ border-color: #222;
+ }
+
+ .nav-archived a.active {
+ color: #ccc;
+ background-color: #333;
+ border-color: #555;
+ }
+
+ .nav-archived a:hover {
+ color: #999;
+ border-color: #444;
+ }
+
.description {
margin-bottom: 20px;
text-align: center;
@@ -423,11 +448,18 @@
Payment Reconciliation
diff --git a/tests/test_app.py b/tests/test_app.py
index 02276fc..2d5ac23 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -130,5 +130,65 @@ class TestWebApp(unittest.TestCase):
self.assertIn(b'OK', response.data)
self.assertIn(b'?', response.data)
+ @patch('app.get_cached_data', side_effect=_bypass_cache)
+ @patch('app.fetch_sheet_data')
+ @patch('app.fetch_exceptions', return_value={})
+ @patch('app.get_members_with_fees')
+ def test_adults_route(self, mock_get_members, mock_exceptions, mock_fetch_sheet, mock_cache):
+ """Test that /adults returns 200 and shows combined matches"""
+ mock_get_members.return_value = (
+ [('Test Member', 'A', {'2026-01': (750, 4)})],
+ ['2026-01']
+ )
+ mock_fetch_sheet.return_value = [{
+ 'date': '2026-01-01',
+ 'amount': 750,
+ 'person': 'Test Member',
+ 'purpose': '2026-01',
+ 'message': 'test payment',
+ 'sender': 'External Bank User',
+ 'inferred_amount': 750
+ }]
+
+ response = self.client.get('/adults')
+ self.assertEqual(response.status_code, 200)
+ self.assertIn(b'Adults Dashboard', response.data)
+ self.assertIn(b'Test Member', response.data)
+ self.assertNotIn(b'OK', response.data)
+ self.assertIn(b'750/750 CZK (4)', response.data)
+
+ @patch('app.get_cached_data', side_effect=_bypass_cache)
+ @patch('app.fetch_sheet_data')
+ @patch('app.fetch_exceptions', return_value={})
+ @patch('app.get_junior_members_with_fees')
+ def test_juniors_route(self, mock_get_junior_members, mock_exceptions, mock_fetch_sheet, mock_cache):
+ """Test that /juniors returns 200, uses single line format, and displays '?' properly"""
+ mock_get_junior_members.return_value = (
+ [
+ ('Junior One', 'J', {'2026-01': (500, 3, 0, 3)}),
+ ('Junior Two', 'X', {'2026-01': ('?', 1, 0, 1)})
+ ],
+ ['2026-01']
+ )
+ mock_exceptions.return_value = {}
+ mock_fetch_sheet.return_value = [{
+ 'date': '2026-01-15',
+ 'amount': 500,
+ 'person': 'Junior One',
+ 'purpose': '2026-01',
+ 'message': '',
+ 'sender': 'Parent',
+ 'inferred_amount': 500
+ }]
+
+ response = self.client.get('/juniors')
+ self.assertEqual(response.status_code, 200)
+ self.assertIn(b'Juniors Dashboard', response.data)
+ self.assertIn(b'Junior One', response.data)
+ self.assertIn(b'Junior Two', response.data)
+ self.assertNotIn(b'OK', response.data)
+ self.assertIn(b'500/500 CZK', response.data)
+ self.assertIn(b'?', response.data)
+
if __name__ == '__main__':
unittest.main()