feat(prezentacja): strefa czasowa z lokalizacji — DST-świadomy offset (PRE-03)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 11m2s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m56s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 34s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 23s
build / build (push) Successful in 1m40s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m12s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m52s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 32s
Testy / Kontrola składni wszystkich warstw (push) Successful in 19s

„Logika dwóch lokalizacji": dotąd offset GMT był ręcznym polem (PRE-19) — trzeba
było go znać i samemu pamiętać o czasie letnim. Teraz liczymy go z lokalizacji.

Sedno wymagania: strefę ustalamy RAZ i trzymamy jako stałą liczbę, żeby drobna
zmiana współrzędnych nie przerzuciła DST i nie „przeskoczyła" Ascendenta na
sąsiedni znak. „Większa miejscowość z bazy" okazuje się zbędna — strefa IANA jest
i tak regionalna, więc wioska daje tę samą strefę co pobliskie miasto.

Jak:
- `timezone.py`: współrzędne → strefa IANA (tzfpy, OFFLINE — bez sieci), a z niej
  offset DLA DATY URODZENIA. `zoneinfo`/`tzdata` znają reguły historyczne i DST:
  Kraków 1984 to +1h zimą, +2h latem; Katmandu +5:45. Degraduje się do None (brak
  biblioteki / punkt bez strefy / zła data) — wtedy zostaje ręczny offset.
- Endpoint `GET /timezone?lat&lon&date&time` → {tz, offset, dst, label}. 404, gdy
  nie da się ustalić.
- `geo.js`: po wyborze miejsca (mapa / wyszukiwarka / „Tu i teraz") oraz przy
  zmianie DATY (bo DST zależy od pory roku) pobiera offset i wypełnia pole
  tz_offset, pokazując wykrytą strefę („Wykryto: Europe/Warsaw · +2:00 (czas
  letni)"). Pole zostaje edytowalne. Na wejściu podpowiada tylko gdy offset
  wygląda na nieustawiony — nie nadpisuje wartości ręcznie wpisanej i wysłanej.

Zależności (lekkie, offline): tzfpy (wheel Rust) + tzdata (dla zoneinfo w slim-obrazie).

Weryfikacja: żywy serwer — Kraków 1984-06 → +2:00 (czas letni), 1984-01 → +1:00,
Katmandu → +5:45. Testy: +12 strefa (moduł + endpoint), +5 JS. Prezentacja 187.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit was merged in pull request #43.
This commit is contained in:
2026-07-28 22:42:31 +02:00
parent 9a5d61b5eb
commit 4bdfb673cc
6 changed files with 263 additions and 0 deletions
+14
View File
@@ -430,6 +430,20 @@ def geocode_reverse(lat: float, lon: float):
raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}") 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") @app.get("/health")
def health() -> dict: def health() -> dict:
return {"status": "ok", "layer": "presentation"} return {"status": "ok", "layer": "presentation"}
+47
View File
@@ -23,6 +23,21 @@
var resultsEl = document.getElementById('geoResults'); var resultsEl = document.getElementById('geoResults');
var mapEl = document.getElementById('map'); 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 num = function (el) { var v = parseFloat(el.value); return isNaN(v) ? 0 : v; };
var curLat = function () { return num(latEl); }; var curLat = function () { return num(latEl); };
var curLon = function () { return num(lonEl); }; var curLon = function () { return num(lonEl); };
@@ -53,6 +68,28 @@
marker.setLatLng([lat, lon]); marker.setLatLng([lat, lon]);
if (!fromMap) map.setView([lat, lon], Math.max(map.getZoom(), 12)); if (!fromMap) map.setView([lat, lon], Math.max(map.getZoom(), 12));
if (fromMap) reverseName(lat, lon); 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) { function reverseName(lat, lon) {
@@ -130,6 +167,16 @@
marker.setLatLng([curLat(), curLon()]); marker.setLatLng([curLat(), curLon()]);
map.setView([curLat(), curLon()], Math.max(map.getZoom(), 12)); map.setView([curLat(), curLon()], Math.max(map.getZoom(), 12));
reverseName(curLat(), curLon()); 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();
}); });
})(); })();
+68
View File
@@ -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}
+5
View File
@@ -5,3 +5,8 @@ jinja2>=3.1
python-multipart>=0.0.20 python-multipart>=0.0.20
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF # Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
cryptography>=44.0 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
@@ -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.""" """Odświeżenie nazwy nie może zastąpić przesunięcia pineski i widoku mapy."""
body = _handler_body() body = _handler_body()
assert "marker.setLatLng" in body and "map.setView" in 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ę
@@ -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