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
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:
@@ -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"}
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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,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
|
||||||
Reference in New Issue
Block a user