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
+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}