From da804708e865406fe1461af00209f947538cd62b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 22:08:47 +0000 Subject: [PATCH 1/2] fix(receipts): open a receipt over the page in the installed app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the app on the home screen "Bonnetje openen" in Verwerken, and a receipt's thumbnail on an expense or in the tray, opened the file in a new tab that took over the screen with no way back, and what was typed in Verwerken was gone. There it now opens in a dialog with "Sluiten", as "Voorbeeld-pdf" does: a PDF in a frame, a photo as itself. In a browser tab, the desktop app and without JS nothing changes. A check makes every new tab inside Huismus carry an answer for the app. Playbook: n.v.t. — bugfix in Huismus zelf, geen werkwijze Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W6o7JHCnFJVV7ub63VfxGH --- BUGS-BY-HAND.md | 1 + huismus/core/static/core/app.js | 35 ++++++-- huismus/core/static/core/huismus.css | 1 + .../templates/expenses/_receipt_dialog.html | 16 ++++ huismus/templates/expenses/form.html | 4 +- huismus/templates/expenses/process.html | 3 +- huismus/templates/expenses/tray.html | 4 +- tests/core/test_pwa.py | 28 ++++++ tests/e2e/test_process.py | 87 +++++++++++++++++++ 9 files changed, 171 insertions(+), 8 deletions(-) create mode 100644 huismus/templates/expenses/_receipt_dialog.html diff --git a/BUGS-BY-HAND.md b/BUGS-BY-HAND.md index f9aaf34..e937bc8 100644 --- a/BUGS-BY-HAND.md +++ b/BUGS-BY-HAND.md @@ -60,3 +60,4 @@ toe (vaak in `tests/e2e/test_every_page.py`) en zet hier een regel bij. | 2026-11-07 | De eigenaar zocht de plek voor de API-sleutel van zijn eigen AI-dienst en vond hem niet: die stond onder Instellingen → "Bonnetjes lezen", een naam zonder AI of sleutel, en op AI-assistenten (waar hij keek) maakt Huismus juist zelf een sleutel (deze PR) | instelling vindbaar alleen voor wie weet waarvoor hij dient | de kaart heet "Bonnetjes lezen met AI" en noemt de API-sleutel, AI-assistenten wijst ernaar; `test_the_card_says_it_is_where_your_own_ai_key_goes`, `test_the_page_points_to_where_your_own_ai_key_goes`. Vindbaarheid zelf: geen geautomatiseerde check | | 2026-11-13 | Desktop-app: "Koppel met Claude" deed niets, en geen enkel formulier meer, tot het venster dichtging. Een htmx-verzoek ging weg van een deel dat zichzelf ververst; een paginawissel verving dat deel voordat het antwoord kwam. htmx meldt het einde alleen aan het element zelf, dus app.js bleef wachten en hield elk POST-formulier tegen (de eigenaar; deze PR) | boekhouding van de pagina hangt aan een htmx-event van een element dat weg kan zijn | `test_browser`: `test_a_request_whose_element_is_gone_does_not_hold_up_the_forms` (geldt voor elk htmx-verzoek: app.js telt nu ook het einde van het verzoek zelf, `loadend`) | | 2026-11-13 | Bonnetjes lezen met Anthropic (Claude Haiku 5.5) niet op te slaan: "Anthropic gaf een antwoord dat Huismus niet begrijpt". Huismus stuurde `temperature: 0` mee en nieuwere modellen weigeren dat met een 400; wat de dienst zei, kwam nergens in het log (de eigenaar, op de Pi; deze PR) | verzoek naar een externe dienst met een instelling die nieuwere versies weigeren | `tests/ai_fakes.py`: de nep-dienst weigert `temperature`, `top_p` en `top_k` bij elk verzoek in elke test; een mislukte test van de dienst zet het antwoord van de dienst in het log (`test_a_failed_test_logs_what_the_service_said`) | +| 2026-11-13 | In de app op het beginscherm (iPhone) opende "Bonnetje openen" bij Verwerken de pdf over het hele scherm, zonder weg terug; wat je had ingevuld was weg. Ook de bonnetjes bij een uitgave en bij Bonnetjes zonder uitgave (de eigenaar, op de Pi; deze PR) | nieuw tabblad binnen Huismus in de geïnstalleerde app | `tests/core/test_pwa.py`: `test_every_new_tab_inside_huismus_has_a_way_back_in_the_installed_app` (elke link of knop naar een nieuw tabblad binnen Huismus heeft het antwoord van app.js); `test_in_the_installed_app_a_receipt_opens_over_verwerken` | diff --git a/huismus/core/static/core/app.js b/huismus/core/static/core/app.js index c9ae622..4e7c14c 100644 --- a/huismus/core/static/core/app.js +++ b/huismus/core/static/core/app.js @@ -540,7 +540,7 @@ document.addEventListener("click", async (event) => { // command, with the fallback at the top of this file). In a browser tab, in // the desktop app and without JS it still opens in a new tab. The click // comes before the submit, so the form already goes to the frame. -let previewOpener = null; +let pdfOpener = null; document.addEventListener("click", (event) => { const button = event.target.closest("[data-preview-pdf]"); const dialog = button && document.getElementById(button.dataset.previewPdf); @@ -549,9 +549,34 @@ document.addEventListener("click", (event) => { const frame = template.content.firstElementChild.cloneNode(); template.after(frame); button.formTarget = frame.name; - previewOpener = button; + pdfOpener = button; dialog.showModal(); }); + +// A receipt (: "Bonnetje openen" in Verwerken, a +// thumbnail on an expense or among the receipts without one), in the +// installed app: as with "Voorbeeld-pdf", a new tab there takes over the +// screen with no way back, and in Verwerken what was typed would be gone. +// So it opens in the dialog (expenses/_receipt_dialog.html): a PDF in a +// frame, a photo as itself. In a browser tab, in the desktop app and +// without JS the link still opens the file in a new tab. +document.addEventListener("click", (event) => { + const link = event.target.closest("a[data-view-receipt]"); + const dialog = link && document.getElementById(link.dataset.viewReceipt); + if (!dialog || !window.matchMedia("(display-mode: standalone)").matches) return; + event.preventDefault(); + const template = dialog.querySelector(link.hasAttribute("data-pdf") ? "template[data-pdf]" : "template[data-photo]"); + const shown = template.content.firstElementChild.cloneNode(); + shown.src = link.getAttribute("href"); + if (shown.tagName === "IMG") shown.alt = link.dataset.label; + else shown.title = link.dataset.label; + shown.dataset.shown = ""; + dialog.querySelector("h2").textContent = link.dataset.name; + template.after(shown); + pdfOpener = link; + dialog.showModal(); +}); + document.addEventListener("click", (event) => { // A tap on the backdrop lands on the dialog itself, not on its content. if (event.target.matches?.("dialog.is-pdf")) event.target.close(); @@ -562,13 +587,13 @@ document.addEventListener( (event) => { if (!event.target.matches?.("dialog.is-pdf")) return; // Gone, so the next preview doesn't first show the previous one. - event.target.querySelector("iframe")?.remove(); + event.target.querySelector("iframe, [data-shown]")?.remove(); // Focus back on the button that opened it; on a phone that one is in // the closed "Meer" menu, so on that menu's button. - const menu = previewOpener?.closest("[popover]"); + const menu = pdfOpener?.closest("[popover]"); const target = menu && !menu.matches(":popover-open") ? document.querySelector(`[popovertarget="${menu.id}"]`) - : previewOpener; + : pdfOpener; target?.focus(); }, true, diff --git a/huismus/core/static/core/huismus.css b/huismus/core/static/core/huismus.css index 7ef24fb..5fb555e 100644 --- a/huismus/core/static/core/huismus.css +++ b/huismus/core/static/core/huismus.css @@ -505,6 +505,7 @@ a:hover { color: var(--primary-hover); } .dialog.is-pdf { width: min(960px, calc(100% - 16px)); max-height: calc(100dvh - 16px); } .dialog.is-pdf .dialog-body { padding: var(--s-4); } .dialog.is-pdf .pdf-frame { height: calc(100dvh - 180px); } +.dialog.is-pdf .dialog-photo { display: block; max-width: 100%; max-height: calc(100dvh - 180px); margin: 0 auto; object-fit: contain; } .dialog.is-static { position: static; display: block; margin: 0; animation: none; } .sheet { margin: 0 0 0 auto; width: min(440px, 100%); max-width: none; height: 100dvh; max-height: none; padding: 0; border: 0; background: var(--surface); color: var(--text); box-shadow: var(--sh-pop); display: none; flex-direction: column; } diff --git a/huismus/templates/expenses/_receipt_dialog.html b/huismus/templates/expenses/_receipt_dialog.html new file mode 100644 index 0000000..5f9492b --- /dev/null +++ b/huismus/templates/expenses/_receipt_dialog.html @@ -0,0 +1,16 @@ +{% load i18n %} +{% comment %}A receipt over the page, in the installed app (app.js, +data-view-receipt): there a new tab takes over the screen with no way back, +and in Verwerken what was typed would be gone. The same answer as +"Voorbeeld-pdf" (documents/_editor_dialogs.html). In a browser tab, in the +desktop app and without JS the link opens the file in a new tab and this +dialog stays unused. The templates keep the page itself free of a frame +until the dialog opens.{% endcomment %} + +
+

+ + +
+
+
diff --git a/huismus/templates/expenses/form.html b/huismus/templates/expenses/form.html index deff1e8..8620bf6 100644 --- a/huismus/templates/expenses/form.html +++ b/huismus/templates/expenses/form.html @@ -48,7 +48,8 @@

{% translate "Bonnetjes" %}

{% for attachment in expense.attachments.all %}
- + {% blocktranslate with name=attachment.filename asvar receipt_label %}Bonnetje {{ name }}{% endblocktranslate %} + {% if attachment.is_pdf %} {% else %}{% endif %} @@ -129,4 +130,5 @@

{% translate "Deze uitgave verwijderen?" %}

{% endif %} +{% include "expenses/_receipt_dialog.html" %} {% endblock %} diff --git a/huismus/templates/expenses/process.html b/huismus/templates/expenses/process.html index 84df7f4..134fccc 100644 --- a/huismus/templates/expenses/process.html +++ b/huismus/templates/expenses/process.html @@ -45,7 +45,7 @@

{% translate "Niets meer te verwerken." %}

{% else %} {{ receipt_label }} {% endif %} - {% translate "Bonnetje openen" %} ↗ + {% translate "Bonnetje openen" %} ↗ {% else %}

{% translate "Geen bonnetje bij deze uitgave." %}

@@ -88,5 +88,6 @@

{% translate "Niets meer te verwerken." %}

+{% include "expenses/_receipt_dialog.html" %} {% endif %} {% endblock %} diff --git a/huismus/templates/expenses/tray.html b/huismus/templates/expenses/tray.html index 4c8c3ef..6c309ab 100644 --- a/huismus/templates/expenses/tray.html +++ b/huismus/templates/expenses/tray.html @@ -36,7 +36,8 @@

{% translate "Bonnetjes zonder uitgave" %}

{% for item in receipts %}{% with receipt=item.receipt %} {% endif %}
+{% include "expenses/_receipt_dialog.html" %} {% endblock %} diff --git a/tests/core/test_pwa.py b/tests/core/test_pwa.py index 21c4378..2bc66a2 100644 --- a/tests/core/test_pwa.py +++ b/tests/core/test_pwa.py @@ -71,3 +71,31 @@ def test_pages_link_the_manifest_and_the_home_screen_icon(client): assert f'' in html assert "core/icons/apple-touch-icon.png" in html assert 'name="theme-color"' in html + + +# Addresses outside Huismus: in the installed app iOS opens those in a sheet +# with "Gereed", so a new tab there is fine. +_ELSEWHERE = ("{{ whats_new_url }}", "{{ authorize_url }}", "{{ release.page_url }}") + + +def test_every_new_tab_inside_huismus_has_a_way_back_in_the_installed_app(): + # In the app on the home screen a new tab of Huismus's own takes over the + # screen with no back button (a receipt in Verwerken, the owner on an + # iPhone, BUGS-BY-HAND.md). Each such link or button needs app.js's + # answer there: a dialog over the page (data-view-receipt, + # data-preview-pdf) or a sheet (data-file). + import re + from pathlib import Path + + templates = Path(__file__).resolve().parents[2] / "huismus" / "templates" + stranded = [] + for path in templates.rglob("*.html"): + if path.parts[-2] == "site": # the public site: no app there + continue + for tag in re.findall(r"<(?:a|button)\b[^>]*target=\"_blank\"[^>]*>", path.read_text()): + href = re.search(r'href="([^"]*)"', tag) + if href and href.group(1) in _ELSEWHERE: + continue + if not re.search(r"data-(view-receipt|preview-pdf|file)\b", tag): + stranded.append(f"{path.relative_to(templates)}: {tag[:120]}") + assert not stranded, stranded diff --git a/tests/e2e/test_process.py b/tests/e2e/test_process.py index f7e55fa..73eae13 100644 --- a/tests/e2e/test_process.py +++ b/tests/e2e/test_process.py @@ -118,3 +118,90 @@ def test_on_a_phone_the_receipt_is_above_the_fields_and_a_pdf_is_not_framed(app) expect(frame).to_be_hidden() assert frame.get_attribute("src") is None expect(app.get_by_role("link", name="Bonnetje openen ↗")).to_be_visible() + + +_INSTALLED_APP = """const own = window.matchMedia.bind(window); +window.matchMedia = (query) => query === "(display-mode: standalone)" + ? { matches: true, media: query, addEventListener() {}, removeEventListener() {} } + : own(query);""" + + +def test_in_the_installed_app_a_receipt_opens_over_verwerken(app): + # In the app on the home screen a new tab took over the screen with no + # way back (the owner, on an iPhone). Playwright can't install the app: + # matchMedia says it is installed. + _stack() + app.add_init_script(_INSTALLED_APP) + app.set_viewport_size({"width": 390, "height": 800}) + app.goto(app.base + "/uitgaven/verwerken/") + dialog = app.locator("#bonnetje-bekijken") + pages = [] + app.context.on("page", lambda page: pages.append(page)) + + # A photo: the image itself, whole, over the page. + app.get_by_role("link", name="Bonnetje openen ↗").click() + expect(dialog).to_be_visible() + photo = dialog.get_by_role("img", name="Bonnetje kassabon.png") + expect(photo).to_be_visible() + assert photo.bounding_box()["width"] <= 390 + dialog.get_by_role("button", name="Sluiten").click() + expect(dialog).to_be_hidden() + expect(photo).to_have_count(0) + + # A PDF: in a frame, and what was typed stays. + app.get_by_role("link", name="Overslaan").click() + expect(app.locator("#verwerken-teller")).to_have_text("2 van 3") + supplier = app.get_by_label("Leverancier") + supplier.fill("NS Reizigers") + url = app.url + for close in ("button", "Escape"): + app.get_by_role("link", name="Bonnetje openen ↗").click() + expect(dialog).to_be_visible() + frame = dialog.locator("iframe.pdf-frame") + expect(frame).to_have_attribute( + "src", Expense.objects.get(supplier="NS").attachments.get().file.url + ) + expect(dialog.get_by_role("button", name="Sluiten")).to_be_in_viewport() + if close == "button": + dialog.get_by_role("button", name="Sluiten").click() + else: + app.keyboard.press("Escape") + expect(dialog).to_be_hidden() + expect(frame).to_have_count(0) + expect(app.get_by_role("link", name="Bonnetje openen ↗")).to_be_focused() + assert app.url == url + expect(supplier).to_have_value("NS Reizigers") + assert pages == [] + + +@pytest.mark.parametrize("where", ["tray", "expense"]) +def test_in_the_installed_app_a_receipt_thumbnail_opens_over_the_page(app, where): + if where == "tray": + receipt = _in_tray("reis.pdf", _pdf([["NS", "blad 1"]])) + path = "/uitgaven/bonnetjes/" + else: + expense = _to_check("NS", 4, "reis.pdf", _pdf([["NS", "blad 1"]])) + receipt = expense.attachments.get() + path = f"/uitgaven/{expense.pk}/" + app.add_init_script(_INSTALLED_APP) + app.set_viewport_size({"width": 390, "height": 800}) + app.goto(app.base + path) + url = app.url + + app.locator(f"a.thumb[href='{receipt.file.url}']").click() + dialog = app.locator("#bonnetje-bekijken") + expect(dialog).to_be_visible() + expect(dialog.locator("iframe.pdf-frame")).to_have_attribute("src", receipt.file.url) + dialog.get_by_role("button", name="Sluiten").click() + expect(dialog).to_be_hidden() + assert app.url == url + + +def test_in_a_browser_tab_a_receipt_still_opens_in_a_new_tab(app): + _stack() + app.set_viewport_size({"width": 390, "height": 800}) + app.goto(app.base + "/uitgaven/verwerken/") + with app.context.expect_page() as opened: + app.get_by_role("link", name="Bonnetje openen ↗").click() + expect(app.locator("#bonnetje-bekijken")).to_be_hidden() + opened.value.close() From 9f0abed9808177e21af9edb6bf6b64d7971f0da5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 22:18:25 +0000 Subject: [PATCH 2/2] fix(receipts): tell the AI service that included amounts are no lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A webshop's invoice lists a levy or recycling fee under an article as "Incl. ... 2,80", already in the article's price. Read as lines of their own, the lines came to more than the total, and Huismus rightly left the receipt in the tray ("de bedragen tellen niet op tot het totaal"). The prompt now says such amounts are never a line. Huismus's own check of the lines against the total stays as it is. Playbook: n.v.t. — bugfix in Huismus zelf, geen werkwijze Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W6o7JHCnFJVV7ub63VfxGH --- BUGS-BY-HAND.md | 1 + huismus/expenses/reading.py | 3 +++ tests/expenses/test_reading.py | 13 +++++++++++++ 3 files changed, 17 insertions(+) diff --git a/BUGS-BY-HAND.md b/BUGS-BY-HAND.md index e937bc8..39e34ed 100644 --- a/BUGS-BY-HAND.md +++ b/BUGS-BY-HAND.md @@ -61,3 +61,4 @@ toe (vaak in `tests/e2e/test_every_page.py`) en zet hier een regel bij. | 2026-11-13 | Desktop-app: "Koppel met Claude" deed niets, en geen enkel formulier meer, tot het venster dichtging. Een htmx-verzoek ging weg van een deel dat zichzelf ververst; een paginawissel verving dat deel voordat het antwoord kwam. htmx meldt het einde alleen aan het element zelf, dus app.js bleef wachten en hield elk POST-formulier tegen (de eigenaar; deze PR) | boekhouding van de pagina hangt aan een htmx-event van een element dat weg kan zijn | `test_browser`: `test_a_request_whose_element_is_gone_does_not_hold_up_the_forms` (geldt voor elk htmx-verzoek: app.js telt nu ook het einde van het verzoek zelf, `loadend`) | | 2026-11-13 | Bonnetjes lezen met Anthropic (Claude Haiku 5.5) niet op te slaan: "Anthropic gaf een antwoord dat Huismus niet begrijpt". Huismus stuurde `temperature: 0` mee en nieuwere modellen weigeren dat met een 400; wat de dienst zei, kwam nergens in het log (de eigenaar, op de Pi; deze PR) | verzoek naar een externe dienst met een instelling die nieuwere versies weigeren | `tests/ai_fakes.py`: de nep-dienst weigert `temperature`, `top_p` en `top_k` bij elk verzoek in elke test; een mislukte test van de dienst zet het antwoord van de dienst in het log (`test_a_failed_test_logs_what_the_service_said`) | | 2026-11-13 | In de app op het beginscherm (iPhone) opende "Bonnetje openen" bij Verwerken de pdf over het hele scherm, zonder weg terug; wat je had ingevuld was weg. Ook de bonnetjes bij een uitgave en bij Bonnetjes zonder uitgave (de eigenaar, op de Pi; deze PR) | nieuw tabblad binnen Huismus in de geïnstalleerde app | `tests/core/test_pwa.py`: `test_every_new_tab_inside_huismus_has_a_way_back_in_the_installed_app` (elke link of knop naar een nieuw tabblad binnen Huismus heeft het antwoord van app.js); `test_in_the_installed_app_a_receipt_opens_over_verwerken` | +| 2026-11-13 | Factuur van een webwinkel bleef liggen met "Niet gelezen: de bedragen tellen niet op tot het totaal": onder een artikel stonden bedragen met "Incl." (een heffing, een recyclingbijdrage) die al in de prijs zaten; vermoedelijk telde het model die als losse regels mee (de eigenaar, op de Pi; deze PR) | model leest een bedrag dat al in een ander zit als eigen regel | nog geen: wat een model leest, test de poort niet. De vraag aan het model noemt het nu (`test_the_service_is_told_that_included_amounts_are_no_lines`); Huismus's eigen check op het totaal blijft | diff --git a/huismus/expenses/reading.py b/huismus/expenses/reading.py index 2b69839..b968164 100644 --- a/huismus/expenses/reading.py +++ b/huismus/expenses/reading.py @@ -192,6 +192,9 @@ def seen() -> None: Rules: - Never guess. Copy amounts as the receipt shows them; use null for what isn't there. - One line per VAT rate is enough. The lines' amounts add up to the total. +- An amount shown as included in an article ("incl.", "inclusief", "including", e.g. + a recycling fee or a levy) is already in that article's price: never a line of + its own, and never added to the total. - "vat": the VAT amount the receipt states for that line or rate. null when the receipt shows no VAT; 0 only when the receipt says 0. - "vat_rate": the VAT rate the receipt states for that line: "21", "9", "0" (0% or no diff --git a/tests/expenses/test_reading.py b/tests/expenses/test_reading.py index 3f99d33..bcb88e6 100644 --- a/tests/expenses/test_reading.py +++ b/tests/expenses/test_reading.py @@ -116,6 +116,19 @@ def test_what_is_sent_is_the_receipt_as_the_assistant_reads_it(service): assert content[1]["image_url"]["url"].startswith("data:image/jpeg;base64,") +def test_the_service_is_told_that_included_amounts_are_no_lines(service): + # A webshop's invoice lists a levy or recycling fee under an article as + # "Incl. ... 2,80": already in the article's price. Read as a line of + # its own, the lines came to more than the total and the receipt stayed + # in the tray ("de bedragen tellen niet op tot het totaal"). + _receipt() + service.answer(ANSWER) + reading.read_waiting() + text = service.requests[-1]["messages"][0]["content"][0]["text"] + assert '"incl."' in text + assert "already in that article's price" in text + + def test_a_pdf_with_nothing_to_read_is_never_sent(service): from pypdf import PdfWriter