diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py index b77fbc0..6d6b524 100644 --- a/services/presentation/app/main.py +++ b/services/presentation/app/main.py @@ -430,6 +430,20 @@ def geocode_reverse(lat: float, lon: float): raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}") +@app.get("/timezone") +def timezone_lookup(lat: float, lon: float, date: str = "", time: str = "12:00"): + """Współrzędne + data → strefa IANA i DST-świadomy offset GMT (PRE-03). + + Liczone OFFLINE (tzfpy + zoneinfo), więc brak internetu nie przeszkadza. 404, + gdy strefy nie da się ustalić — front zostawia wtedy ręczny offset.""" + from app import timezone as tzmod + + res = tzmod.resolve(lat, lon, date, time) + if not res: + raise HTTPException(status_code=404, detail="Nie ustalono strefy dla tego punktu.") + return res + + @app.get("/health") def health() -> dict: return {"status": "ok", "layer": "presentation"} diff --git a/services/presentation/app/static/geo.js b/services/presentation/app/static/geo.js index 3dbb4de..bcfa07c 100644 --- a/services/presentation/app/static/geo.js +++ b/services/presentation/app/static/geo.js @@ -23,6 +23,21 @@ var resultsEl = document.getElementById('geoResults'); var mapEl = document.getElementById('map'); + // ---- strefa czasowa z lokalizacji (PRE-03) ---- + // Offset GMT liczymy ze współrzędnych + daty (DST-świadomie), żeby użytkownik + // nie musiał go znać ani pamiętać o czasie letnim. Podpowiedź trafia do pola + // tz_offset (PRE-19), które zostaje edytowalne — to pomoc, nie kaganiec. + var tzEl = document.querySelector('input[name=tz_offset]'); + var dateEl = document.querySelector('input[name=date]'); + var timeEl = document.querySelector('input[name=time]'); + var tzNote = null; + if (tzEl && tzEl.parentNode) { + tzNote = document.createElement('span'); + tzNote.className = 'muted small'; + tzNote.id = 'tzNote'; + tzEl.parentNode.appendChild(tzNote); + } + var num = function (el) { var v = parseFloat(el.value); return isNaN(v) ? 0 : v; }; var curLat = function () { return num(latEl); }; var curLon = function () { return num(lonEl); }; @@ -53,6 +68,28 @@ marker.setLatLng([lat, lon]); if (!fromMap) map.setView([lat, lon], Math.max(map.getZoom(), 12)); if (fromMap) reverseName(lat, lon); + resolveTz(); // nowe współrzędne → odśwież strefę/offset + } + + // Współrzędne + data → offset GMT (DST-świadomy). Wypełnia tz_offset i pokazuje, + // jaką strefę wykryto. Bez pola/daty/biblioteki po cichu pasuje — zostaje + // ręczny offset. Offset trzymamy potem jako stałą liczbę: to on decyduje przy + // przeliczaniu, więc dokładne współrzędne nie przerzucą już czasu letniego. + function resolveTz() { + if (!tzEl || !dateEl || !dateEl.value) return; + var lat = curLat(), lon = curLon(); + if (!lat && !lon) return; // 0,0 = brak lokalizacji + var url = '/timezone?lat=' + lat + '&lon=' + lon + + '&date=' + encodeURIComponent(dateEl.value) + + '&time=' + encodeURIComponent((timeEl && timeEl.value) || '12:00'); + fetch(url) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { + if (!d) { if (tzNote) tzNote.textContent = ''; return; } + setVal(tzEl, d.offset); // change → formsync podłapie + if (tzNote) tzNote.textContent = 'Wykryto: ' + d.label; + }) + .catch(function () {}); } function reverseName(lat, lon) { @@ -130,6 +167,16 @@ marker.setLatLng([curLat(), curLon()]); map.setView([curLat(), curLon()], Math.max(map.getZoom(), 12)); reverseName(curLat(), curLon()); + resolveTz(); // „Tu i teraz" → nowe miejsce → nowa strefa }); + + // Zmiana daty przelicza offset — czas letni zależy od daty (inny w czerwcu + // niż w styczniu), więc bez tego offset zostałby z poprzedniej pory roku. + if (dateEl) dateEl.addEventListener('change', resolveTz); + + // Na wejściu podpowiadamy strefę TYLKO gdy offset wygląda na nieustawiony + // (0 lub pusto) — żeby nie nadpisać wartości, którą użytkownik wpisał ręcznie + // i wysłał. Przy każdej późniejszej zmianie miejsca/daty już aktualizujemy. + if (tzEl && (tzEl.value === '' || parseFloat(tzEl.value) === 0)) resolveTz(); }); })(); diff --git a/services/presentation/app/timezone.py b/services/presentation/app/timezone.py new file mode 100644 index 0000000..3ebae98 --- /dev/null +++ b/services/presentation/app/timezone.py @@ -0,0 +1,68 @@ +"""Strefa czasowa z lokalizacji (PRE-03) — „logika dwóch lokalizacji". + +Cel po ludzku: żeby użytkownik nie musiał znać offsetu GMT ani pamiętać o czasie +letnim. Z dokładnych współrzędnych bierzemy strefę IANA (`tzfpy`, OFFLINE — bez +zapytań do sieci, więc działa też bez internetu), a z niej — offset DLA DATY +URODZENIA. `zoneinfo` zna reguły historyczne i DST, więc np. Kraków 1984 to +1h +zimą, +2h latem, a Katmandu to +5:45. + +Sedno PRE-03: offset ustalamy RAZ i trzymamy jako stałą liczbę w formularzu — +przeliczenie dla dokładnej lokalizacji już nim nie rusza. Dzięki temu drobna +zmiana współrzędnych nie przerzuca strefy ani czasu letniego i nie „przeskakuje" +Ascendenta na sąsiedni znak. „Większa miejscowość" z wymagania jest zbędna: +strefa IANA jest i tak regionalna, więc wioska daje tę samą strefę co pobliskie +miasto. +""" +from __future__ import annotations + +from datetime import datetime +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +try: # zależność opcjonalna — brak biblioteki + from tzfpy import get_tz # nie może wywalić strony, tylko wyłącza podpowiedź +except Exception: # pragma: no cover + get_tz = None + + +def _fmt_offset(hours: float) -> str: + sign = "+" if hours >= 0 else "−" + total = int(round(abs(hours) * 60)) + return f"{sign}{total // 60}:{total % 60:02d}" + + +def resolve(lat: float, lon: float, date: str, time: str = "12:00") -> dict | None: + """(lat, lon, data lokalna, godzina lokalna) → {tz, offset, dst, label}. + + Zwraca None, gdy strefy nie da się ustalić (brak biblioteki, punkt bez strefy, + niepoprawna data) — wtedy zostaje ręczny offset, nic się nie psuje. + `offset` jest w godzinach (np. 5.75 dla +5:45), DST-świadomy dla podanej daty. + """ + if get_tz is None: + return None + try: + tz = get_tz(float(lon), float(lat)) # UWAGA: tzfpy przyjmuje (lon, lat) + except Exception: + return None + if not tz: + return None + try: + zone = ZoneInfo(tz) + except (ZoneInfoNotFoundError, ValueError): + return None + + # Wall-time w tej strefie dla podanej daty → offset i czy czas letni jest aktywny. + try: + local = datetime.fromisoformat(f"{date}T{time or '12:00'}") + except ValueError: + try: + local = datetime.fromisoformat(date).replace(hour=12) + except ValueError: + return None + local = local.replace(tzinfo=zone) + off = local.utcoffset() + if off is None: + return None + hours = round(off.total_seconds() / 3600.0, 2) + dst = bool(local.dst()) + label = f"{tz} · {_fmt_offset(hours)}" + (" (czas letni)" if dst else "") + return {"tz": tz, "offset": hours, "dst": dst, "label": label} diff --git a/services/presentation/requirements.txt b/services/presentation/requirements.txt index faca6fc..85290a6 100644 --- a/services/presentation/requirements.txt +++ b/services/presentation/requirements.txt @@ -5,3 +5,8 @@ jinja2>=3.1 python-multipart>=0.0.20 # Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF cryptography>=44.0 +# Strefa czasowa z lokalizacji (PRE-03): współrzędne → strefa IANA (offline, lekki +# wheel Rust), a offset/DST liczy stdlib zoneinfo. tzdata na wypadek slim-obrazu +# bez systemowej bazy stref. +tzfpy>=0.15 +tzdata>=2024.1 diff --git a/services/presentation/tests/test_static_js.py b/services/presentation/tests/test_static_js.py index a26ed2b..6b12478 100644 --- a/services/presentation/tests/test_static_js.py +++ b/services/presentation/tests/test_static_js.py @@ -86,3 +86,39 @@ def test_now_button_still_syncs_map(): """Odświeżenie nazwy nie może zastąpić przesunięcia pineski i widoku mapy.""" body = _handler_body() assert "marker.setLatLng" in body and "map.setView" in body + + +# ---- strefa czasowa z lokalizacji (PRE-03) ---- + +def test_setcoords_resolves_timezone(): + """Nowe współrzędne (mapa/wyszukiwarka) mają odświeżyć strefę i offset.""" + src = _geo_source() + body = src[src.index("function setCoords"):src.index("\n }", src.index("function setCoords"))] + assert "resolveTz()" in body + + +def test_now_button_resolves_timezone(): + """„Tu i teraz" to nowe miejsce → nowa strefa.""" + assert "resolveTz()" in _handler_body() + + +def test_date_change_recomputes_timezone(): + """Czas letni zależy od daty — zmiana daty musi przeliczyć offset.""" + src = _geo_source() + assert "dateEl.addEventListener('change', resolveTz)" in src + + +def test_timezone_hint_does_not_clobber_manual_offset_on_load(): + """Na wejściu podpowiadamy strefę TYLKO gdy offset wygląda na nieustawiony — + inaczej nadpisalibyśmy wartość ręcznie wpisaną i wysłaną przez użytkownika.""" + src = _geo_source() + assert "parseFloat(tzEl.value) === 0" in src + + +def test_timezone_fills_offset_field_and_shows_zone(): + src = _geo_source() + start = src.index("function resolveTz") + body = src[start:src.index("\n }", start)] + assert "/timezone?lat=" in body + assert "setVal(tzEl, d.offset)" in body # wypełnia pole offsetu + assert "d.label" in body # pokazuje wykrytą strefę diff --git a/services/presentation/tests/test_timezone.py b/services/presentation/tests/test_timezone.py new file mode 100644 index 0000000..07ac0d3 --- /dev/null +++ b/services/presentation/tests/test_timezone.py @@ -0,0 +1,93 @@ +"""Strefa czasowa z lokalizacji (PRE-03). + +Najważniejsze: offset ma być DST-świadomy i liczony DLA DATY URODZENIA (reguły +historyczne), a nie „na sztywno" — inaczej wróciłby dokładnie ten błąd, który +PRE-03 ma usunąć (pomylona godzina / czas letni → przeskok Ascendenta). +""" +import pytest + +from app import timezone as tz + + +requires_tzfpy = pytest.mark.skipif(tz.get_tz is None, reason="brak tzfpy w środowisku") + + +@requires_tzfpy +def test_krakow_summer_is_plus_two_with_dst(): + r = tz.resolve(50.0647, 19.945, "1984-06-01", "11:20") + assert r["tz"] == "Europe/Warsaw" + assert r["offset"] == 2.0 + assert r["dst"] is True + assert "czas letni" in r["label"] + + +@requires_tzfpy +def test_krakow_winter_is_plus_one_without_dst(): + r = tz.resolve(50.0647, 19.945, "1984-01-15", "10:00") + assert r["offset"] == 1.0 + assert r["dst"] is False + assert "czas letni" not in r["label"] + + +@requires_tzfpy +def test_kathmandu_is_five_forty_five(): + """Katmandu +5:45 — sztandarowy przypadek offsetu w 15-minutowym kroku.""" + r = tz.resolve(27.7172, 85.3240, "2000-05-01", "08:00") + assert r["tz"] == "Asia/Kathmandu" + assert r["offset"] == 5.75 + assert "+5:45" in r["label"] + + +@requires_tzfpy +def test_new_york_dst_differs_by_season(): + summer = tz.resolve(40.7128, -74.0060, "2020-07-01", "12:00") + winter = tz.resolve(40.7128, -74.0060, "2020-01-01", "12:00") + assert summer["offset"] == -4.0 and summer["dst"] is True # EDT + assert winter["offset"] == -5.0 and winter["dst"] is False # EST + + +@requires_tzfpy +def test_missing_time_defaults_to_noon_not_crash(): + r = tz.resolve(50.0647, 19.945, "1984-06-01", "") + assert r and r["offset"] == 2.0 + + +@requires_tzfpy +def test_bad_date_returns_none_not_exception(): + assert tz.resolve(50.0, 19.9, "nonsense", "12:00") is None + + +def test_no_library_degrades_to_none(monkeypatch): + """Brak tzfpy nie może wywalić — po prostu bez podpowiedzi (ręczny offset zostaje).""" + monkeypatch.setattr(tz, "get_tz", None) + assert tz.resolve(50.0, 19.9, "1984-06-01", "11:20") is None + + +# ---- endpoint /timezone ---- + +def _client(): + import os + + os.environ.pop("APP_PASSWORD", None) # bez bramki logowania w teście + from starlette.testclient import TestClient + + from app.main import app + + return TestClient(app) + + +@requires_tzfpy +def test_endpoint_returns_offset_for_a_point(): + r = _client().get("/timezone", params={"lat": 50.0647, "lon": 19.945, + "date": "1984-06-01", "time": "11:20"}) + assert r.status_code == 200 + body = r.json() + assert body["offset"] == 2.0 and body["tz"] == "Europe/Warsaw" + + +def test_endpoint_404_when_zone_cannot_be_resolved(monkeypatch): + """Gdy strefy nie da się ustalić → 404, front zostawia ręczny offset.""" + from app import timezone as tzmod + monkeypatch.setattr(tzmod, "get_tz", None) + r = _client().get("/timezone", params={"lat": 0.0, "lon": 0.0, "date": "2000-01-01"}) + assert r.status_code == 404