diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py
index bce50fc..64807e0 100644
--- a/services/presentation/app/main.py
+++ b/services/presentation/app/main.py
@@ -10,8 +10,11 @@ Strona główna „/" = wprowadzenie danych horoskopu i podgląd policzonych poz
# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
from __future__ import annotations
+import hashlib
import json
from datetime import datetime, timedelta, timezone
+from functools import lru_cache
+from pathlib import Path
import httpx
from fastapi import FastAPI, Form, HTTPException, Query, Request
@@ -30,6 +33,29 @@ logic = LogicClient()
security.install(app) # logowanie + limit żądań (LOG-32)
+# ── cache-busting statyki (PRE-26) ─────────────────────────────────────────
+# Po deployu przeglądarka trzymała stare styles.css / *.js (ten sam URL → cache).
+# Doklejamy do URL-a krótki HASH TREŚCI pliku: zmieni się plik → zmieni się URL →
+# przeglądarka pobierze nowy; bez zmian URL zostaje ten sam (cache działa dalej).
+# Hash liczony raz na proces (lru_cache) — nowy pod po deployu = świeży hash.
+_STATIC_DIR = Path("app/static")
+
+
+@lru_cache(maxsize=None)
+def _asset_version(name: str) -> str:
+ try:
+ return hashlib.md5((_STATIC_DIR / name).read_bytes()).hexdigest()[:8]
+ except OSError:
+ return "0" # brak pliku nie może wywrócić strony
+
+
+def static_url(name: str) -> str:
+ return f"/static/{name}?v={_asset_version(name)}"
+
+
+templates.env.globals["static"] = static_url
+
+
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
"""Z lokalnej daty/godziny + przesunięcia strefy → moment UTC.
diff --git a/services/presentation/app/templates/_location_picker.html b/services/presentation/app/templates/_location_picker.html
index 8edc006..e81b77d 100644
--- a/services/presentation/app/templates/_location_picker.html
+++ b/services/presentation/app/templates/_location_picker.html
@@ -1,7 +1,7 @@
{# Wyszukiwarka lokalizacji + interaktywna mapa (OpenStreetMap / Leaflet, bez klucza API).
Wklejana do formularzy z polami input[name=lat] / input[name=lon]. Samowystarczalna:
ładuje CSS/JS Leafleta (vendorowane lokalnie) oraz geo.js. Kafelki mapy lecą z OSM. #}
-
+
-
-
+
+
diff --git a/services/presentation/app/templates/base.html b/services/presentation/app/templates/base.html
index 71ec1e2..43c99c9 100644
--- a/services/presentation/app/templates/base.html
+++ b/services/presentation/app/templates/base.html
@@ -4,15 +4,15 @@
astrololo · {% block title %}{% endblock %}
-
+
{# Wspólne dane formularza między zakładkami (PRE-21). W z `defer`
CELOWO: skrypty defer wykonują się w kolejności dokumentu, więc ten zdąży
odtworzyć współrzędne, ZANIM geo.js zbuduje mapę — mapa startuje od razu
we właściwym miejscu, bez przestawiania. #}
-
+
{# Powiększanie kosmogramu (PRE-25) — globalnie, bo koło pojawi się też na
zakładce „Skompiluj"; skrypt sam sprawdza, czy jest co powiększać. #}
-
+
diff --git a/services/presentation/app/templates/chart.html b/services/presentation/app/templates/chart.html
index 6ef6549..c97f738 100644
--- a/services/presentation/app/templates/chart.html
+++ b/services/presentation/app/templates/chart.html
@@ -340,5 +340,5 @@
{% endif %}
{% endif %}
-
+
{% endblock %}
diff --git a/services/presentation/app/templates/compile.html b/services/presentation/app/templates/compile.html
index a8dfb57..aade5de 100644
--- a/services/presentation/app/templates/compile.html
+++ b/services/presentation/app/templates/compile.html
@@ -135,7 +135,7 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
{# Magazyny wczytujemy dla ich API odczytu. Ich własne UI samo się wyłącza —
każdy sprawdza, czy jego kontener jest na stronie (tu go nie ma). #}
-
-
-
+
+
+
{% endblock %}
diff --git a/services/presentation/app/templates/interpret.html b/services/presentation/app/templates/interpret.html
index f3772ca..29c7691 100644
--- a/services/presentation/app/templates/interpret.html
+++ b/services/presentation/app/templates/interpret.html
@@ -97,10 +97,10 @@
{% endfor %}
{% endif %}
-
-
-
-
+
+
+
+
{# po progress.js — nasłuchuje zdarzenia o gotowej interpretacji (PRE-23) #}
-
+
{% endblock %}
diff --git a/services/presentation/app/templates/timeline.html b/services/presentation/app/templates/timeline.html
index 8c0fc59..90cf9e2 100644
--- a/services/presentation/app/templates/timeline.html
+++ b/services/presentation/app/templates/timeline.html
@@ -91,10 +91,10 @@
-
-
-
-
+
+
+
+
{# po progress.js — nasłuchuje zdarzenia, które tamten wysyła po gotowym horoskopie #}
-
+
{% endblock %}
diff --git a/services/presentation/tests/test_cache_busting.py b/services/presentation/tests/test_cache_busting.py
new file mode 100644
index 0000000..56d37a6
--- /dev/null
+++ b/services/presentation/tests/test_cache_busting.py
@@ -0,0 +1,41 @@
+"""Cache-busting plików statycznych (PRE-26).
+
+Po deployu przeglądarka trzymała stare styles.css / *.js (ten sam URL). Doklejamy
+hash treści → zmiana pliku zmienia URL. Regresja byłaby CICHA (użytkownik widzi
+stary interfejs), więc pilnujemy strukturalnie.
+"""
+import os
+import pathlib
+import re
+
+os.environ.pop("APP_PASSWORD", None) # import app.main bez bramki logowania
+
+from app.main import _asset_version, static_url
+
+TEMPLATES = pathlib.Path(__file__).resolve().parents[1] / "app" / "templates"
+
+
+def test_static_url_carries_content_hash():
+ assert re.fullmatch(r"/static/styles\.css\?v=[0-9a-f]{8}", static_url("styles.css"))
+
+
+def test_version_is_content_based_so_differs_between_files():
+ assert _asset_version("styles.css") != _asset_version("formsync.js")
+
+
+def test_missing_file_does_not_crash():
+ assert static_url("nie-ma-takiego.js").endswith("?v=0")
+
+
+def test_no_template_serves_raw_static_paths():
+ """Żaden href/src nie może iść na surowe /static/... — omijałoby cache-busting."""
+ for f in TEMPLATES.glob("*.html"):
+ txt = f.read_text(encoding="utf-8")
+ assert 'src="/static/' not in txt, f"{f.name}: surowy src bez cache-bustingu"
+ assert 'href="/static/' not in txt, f"{f.name}: surowy href bez cache-bustingu"
+
+
+def test_base_uses_helper_for_stylesheet_and_scripts():
+ base = (TEMPLATES / "base.html").read_text(encoding="utf-8")
+ assert "static('styles.css')" in base
+ assert "static('formsync.js')" in base
diff --git a/services/presentation/tests/test_compile.py b/services/presentation/tests/test_compile.py
index cfb9e50..1f73ec9 100644
--- a/services/presentation/tests/test_compile.py
+++ b/services/presentation/tests/test_compile.py
@@ -106,9 +106,10 @@ def test_stores_are_loaded_on_the_tab():
def test_stores_load_before_the_assembler():
- """Szukamy TAGÓW skryptów, nie samych nazw — te padają też w komentarzach."""
+ """Szukamy TAGÓW skryptów, nie samych nazw — te padają też w komentarzach.
+ Statyka jedzie przez helper cache-bustingu (PRE-26): src="{{ static('X') }}"."""
def tag(name):
- return TPL.index('src="/static/' + name + '"')
+ return TPL.index("static('" + name + "')")
assert tag("natal.js") < tag("compile.js")
assert tag("predictions.js") < tag("compile.js")