Compare commits

..

4 Commits

Author SHA1 Message Date
gitea 8ebce816cd feat(prezentacja): eksport wyników do Excela — tabela robocza (DAN-23/PRE-10)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m29s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m32s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 16s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 11s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Has been cancelled
Testy / Build obrazu silnika B (swisseph) (push) Has been cancelled
Testy / Kontrola składni wszystkich warstw (push) Has been cancelled
Testy / Testy warstwy logicznej (silnik) (push) Has been cancelled
build / build (push) Successful in 53s
Astrolog chce PRACOWAĆ z dopasowaniami: filtrować, sortować, zaznaczać, usuwać —
najwygodniej w Excelu. Raport z logiki jest zagnieżdżony (obiekt → fasety →
próbki), więc spłaszczamy go do JEDNEJ płaskiej tabeli: wiersz = jedno
dopasowanie z bazy. Kolumny: Obiekt · Faseta · Typ · Token · Sygnifikator ·
Rozwinięcie · Opis/efekt.

- `report_export.py`: `report_to_xlsx(report)` (openpyxl). Auto-filtr + zamrożony
  nagłówek = filtrowanie/sortowanie od razu; „Opis" zawijany. Bez ozdób — materiał
  roboczy. Plik składamy TU, w prezentacji (jak PDF idzie przez render): logika
  liczy, prezentacja formatuje wyjście.
- `/interpret` dostaje akcję `export` → pobranie `.xlsx` (nie strona). Przycisk
  „Pobierz Excel" obok „Szukaj interpretacji".
- Zależność: openpyxl (czysty Python).

Zero swissepha, zero walidacji krzyżowej, zero danych od użytkownika — pierwsza
z iteracji „czysto". Testy: +7 (round-trip pliku: nagłówek, spłaszczenie, auto-
filtr/zamrożenie, pusty-bezpieczny, %, wpięcie w UI). Prezentacja 180.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 15:46:16 +02:00
gitea 52b7c20c2a fix(prezentacja): podsumowanie bierze WSZYSTKIE policzone opcje (stacje, tabele, domy)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m30s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 15s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 10s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m5s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m31s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 15s
Testy / Kontrola składni wszystkich warstw (push) Successful in 10s
build / build (push) Successful in 2m49s
Regresja: „Skompiluj" przeliczało horoskop od nowa z OKROJONYM zestawem opcji
(tylko system domów + zodiak), więc jeśli przy horoskopie policzyłeś stacje,
tabele (żywioły, faza Księżyca…) albo porównanie domów — w podsumowaniu ich NIE
było. Zamiast pokazać to, co policzono, liczyło uboższą wersję.

Zostaje czysty przelicz (bez trzymania dużego wyniku w przeglądarce), ale z TYMI
SAMYMI opcjami co przy horoskopie:
- `formsync`: synchronizuje między zakładkami też opcje — checkboxy (stacje,
  tabele) i wielo-checkbox (porównanie domów). Wcześniej umiał tylko `.value`.
- „Skompiluj" (formularz): dostaje te same opcje; `compile_build` i `compile_pdf`
  przekazują je do logiki (stations/tables/house_systems). `compile.js` wysyła je
  w payloadzie PDF-a.
- Wspólny plik `_result_tables.html`: podsumowanie renderuje DOKŁADNIE te same
  tabele co „Horoskop" (porównanie domów, stacje, aspekty, paralele, antyscja,
  żywioły/faza/godziny, Lots…). Każda sekcja pokazuje się tylko, gdy jej dane są
  w wyniku — opcja niepoliczona → sekcji nie ma (zgodnie z prośbą).
  (TODO: przełączyć też chart.html na ten include, by widoki nie mogły się
  rozjechać — na razie zgodność pilnowana ręcznie, jest komentarz w pliku.)

Weryfikacja: render wspólnego pliku na bogatym wyniku pokazuje wszystkie sekcje.
Testy: +8 (opcje niesione w /compile i /compile/pdf, sync checkboxów/multi,
wspólny include). Prezentacja 202.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 14:01:42 +02:00
gitea 9b1e4dbb20 feat: wiele systemów domów naraz — porównanie obok siebie (PRE-05/LOG-05, A2a)
build / build (push) Successful in 1m3s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m7s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m55s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 36s
Testy / Kontrola składni wszystkich warstw (push) Successful in 25s
Astrolodzy spierają się o systemy domów; teraz można policzyć kilka na raz i
zobaczyć, jak różny podział przesuwa planety między domami.

Domy to geometria z Asc/MC — osie są WSPÓLNE, różni się tylko podział. Prymarny
system zostaje w `cusps`/`house_system`/`positions[].house` (pod kosmogram i
wstecznie — nic się nie zmienia dla dotychczasowych ścieżek). Nowość:
- logika: `build_chart(..., house_systems=[...])` → `result["house_systems"]` =
  pełen zestaw (prymarny pierwszy, bez duplikatów), a `positions[].houses[system]`
  mówi, w którym domu obiekt siedzi wg każdego systemu. Doklejane tylko gdy > 1.
  Nieznany system (np. placidus — dojdzie przez swisseph osobno, A2b) pomijany,
  nie wywala horoskopu.
- endpoint `/chart/positions`: pole `house_systems`; klient prezentacji przekazuje.
- UI: checkboxy „Porównaj systemy domów" (whole sign / equal / porphyry) + tabela
  kusp obok siebie (12 domów × systemy), stan zaznaczeń przeżywa submit.

Na razie 3 systemy z czystej matmy (`houses.py`) — zero zależności, zero walidacji
krzyżowej. Egzotyczne (Placidus/Koch/Regiomontanus/Campanus) dojdą przez endpoint
`/houses` w silniku B (swisseph) jako A2b — maszyneria „naraz" jest już gotowa,
egzotyczne tylko dopiszą kolejne wpisy.

Weryfikacja: żywy /chart/positions — prymarny whole_sign, house_systems
[whole_sign, equal, porphyry], 12 kusp/system, dom per system per obiekt. Testy:
logika +5, prezentacja +6. Logika 270, prezentacja 179.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 00:03:47 +00:00
gitea 4bdfb673cc 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>
2026-07-28 22:42:31 +02:00
18 changed files with 782 additions and 67 deletions
+28 -8
View File
@@ -35,7 +35,8 @@ def _shift_pos(pdict: dict, off: float) -> None:
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN,
lots_method: str = "degree", zodiac: str = Z.TROPICAL) -> dict:
lots_method: str = "degree", zodiac: str = Z.TROPICAL,
house_systems: list[str] | None = None) -> dict:
from app.engine import glyphs as GL
from app.engine.aspects import find_aspects
from app.engine.houses import mean_obliquity
@@ -94,6 +95,16 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
system = house_system if house_system in H.SYSTEMS else H.WHOLE_SIGN
cusp_list = H.cusps(asc, mc, system) # tropikalne — geometria domów jest niezmiennicza
def _cusps_out(cl: list[float]) -> list[dict]:
"""Cuspy → wiersze pod UI/kosmogram: znak, stopień w znaku, długość, glif."""
return [
{"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))],
"in_sign": in_sign(norm360(c - off)),
"decimal": round(norm360(c - off), 6), # długość cuspu — pod kosmogram (PRE-12)
"sign_glyph": GL.sign_glyph(SIGNS[sign_index(norm360(c - off))])}
for i, c in enumerate(cl)
]
result["house_system"] = system
result["angles"] = {
"Asc": _fmt("Asc", asc, off),
@@ -103,16 +114,25 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
}
for a in result["angles"].values(): # glif znaku osi (LOG-22)
a["sign_glyph"] = GL.sign_glyph(a["sign"])
result["cusps"] = [
{"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))],
"in_sign": in_sign(norm360(c - off)),
"decimal": round(norm360(c - off), 6), # długość cuspu — pod kosmogram (PRE-12)
"sign_glyph": GL.sign_glyph(SIGNS[sign_index(norm360(c - off))])}
for i, c in enumerate(cusp_list)
]
result["cusps"] = _cusps_out(cusp_list) # PRYMARNY system — pod kosmogram i wstecz
for pdict, obj in zip(result["positions"], positions):
pdict["house"] = H.assign_house(obj.longitude, cusp_list) # dom po długości tropikalnej
# Wiele systemów domów NARAZ (PRE-05/LOG-05) — do porównania obok siebie.
# Osie (Asc/MC) są wspólne; różni się tylko PODZIAŁ na domy. Prymarny zostaje
# w `cusps`/`house_system` (kosmogram i wstecz), a `house_systems` niesie pełen
# zestaw; `positions[].houses[system]` mówi, w którym domu obiekt siedzi wg
# danego systemu. Dokładamy tylko gdy poproszono o więcej niż jeden.
requested = [system] + [s for s in (house_systems or []) if s in H.SYSTEMS]
ordered = list(dict.fromkeys(requested)) # prymarny pierwszy, bez duplikatów
if len(ordered) > 1:
result["house_systems"] = []
for s in ordered:
cl = cusp_list if s == system else H.cusps(asc, mc, s)
result["house_systems"].append({"system": s, "cusps": _cusps_out(cl)})
for pdict, obj in zip(result["positions"], positions):
pdict.setdefault("houses", {})[s] = H.assign_house(obj.longitude, cl)
# Lots (LOG-08) — wymagają Asc i sekty (dzień/noc)
from app.engine.firdaria import is_day_birth
from app.engine.lots import compute_lots
+4 -2
View File
@@ -43,7 +43,8 @@ class PositionsRequest(BaseModel):
lat: float = 0.0
lon: float = 0.0
objects: list[str] | None = None
house_system: str = "whole_sign" # whole_sign | equal | porphyry
house_system: str = "whole_sign" # whole_sign | equal | porphyry (PRYMARNY — pod kosmogram)
house_systems: list[str] | None = None # PRE-05: dodatkowe systemy do porównania obok
stations: bool = False # licz stacje (LOG-03; wolniejsze — root-findy)
tables: bool = False # tabele dodatkowe (LOG-23; szuka wschodu/zachodu)
zodiac: str = "tropical" # LOG-04: tropical | sidereal_{lahiri,fagan_bradley,krishnamurti} | draconic
@@ -67,7 +68,8 @@ def chart_positions(req: PositionsRequest) -> dict:
engine = get_engine()
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
try:
chart = build_chart(engine, moment, req.house_system, zodiac=req.zodiac)
chart = build_chart(engine, moment, req.house_system, zodiac=req.zodiac,
house_systems=req.house_systems)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
if req.stations:
+41
View File
@@ -25,3 +25,44 @@ def test_house_systems_available(own_engine, reference_moment):
chart = build_chart(own_engine, reference_moment, system)
assert chart["house_system"] == system
assert len(chart["cusps"]) == 12
# ── wiele systemów domów naraz (PRE-05/LOG-05) ──
def test_multiple_house_systems_side_by_side(own_engine, reference_moment):
"""Prymarny zostaje w cusps/house_system; `house_systems` niesie pełen zestaw
do porównania, z prymarnym NA POCZĄTKU i bez duplikatów."""
chart = build_chart(own_engine, reference_moment, "whole_sign",
house_systems=["equal", "porphyry", "whole_sign"])
assert chart["house_system"] == "whole_sign" # prymarny bez zmian
assert [h["system"] for h in chart["house_systems"]] == ["whole_sign", "equal", "porphyry"]
for h in chart["house_systems"]:
assert len(h["cusps"]) == 12
def test_single_system_has_no_comparison_block(own_engine, reference_moment):
"""Bez dodatkowych systemów nie zaśmiecamy wyniku."""
assert "house_systems" not in build_chart(own_engine, reference_moment, "whole_sign")
def test_object_gets_a_house_in_every_system(own_engine, reference_moment):
"""Sedno porównania: każdy obiekt ma dom w KAŻDYM systemie, a prymarny jest
spójny z polem `.house`."""
chart = build_chart(own_engine, reference_moment, "whole_sign", house_systems=["porphyry"])
sun = next(p for p in chart["positions"] if p["name"] == "Sun")
assert set(sun["houses"]) == {"whole_sign", "porphyry"}
assert sun["houses"]["whole_sign"] == sun["house"]
def test_primary_always_first_even_if_not_listed(own_engine, reference_moment):
chart = build_chart(own_engine, reference_moment, "equal", house_systems=["porphyry"])
systems = [h["system"] for h in chart["house_systems"]]
assert systems[0] == "equal" and "porphyry" in systems
def test_unknown_extra_system_is_ignored(own_engine, reference_moment):
"""Nieobsługiwany system (np. placidus — dojdzie przez swisseph osobno) jest
po prostu pomijany, nie wywala horoskopu."""
chart = build_chart(own_engine, reference_moment, "whole_sign",
house_systems=["placidus", "equal"])
assert [h["system"] for h in chart["house_systems"]] == ["whole_sign", "equal"]
@@ -54,6 +54,7 @@ class LogicClient:
stations: bool = False,
zodiac: str = "tropical",
tables: bool = False,
house_systems: list[str] | None = None,
) -> dict[str, Any]:
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
payload = {
@@ -62,6 +63,7 @@ class LogicClient:
"lon": lon,
"objects": objects,
"house_system": house_system,
"house_systems": house_systems,
"stations": stations,
"zodiac": zodiac,
"tables": tables,
+34 -7
View File
@@ -79,12 +79,14 @@ def chart_compute(
lat: float = Form(0.0),
lon: float = Form(0.0),
house_system: str = Form("whole_sign"),
house_systems: list[str] = Form([]), # PRE-05: dodatkowe systemy do porównania
stations: bool = Form(False),
zodiac: str = Form("tropical"),
tables: bool = Form(False),
):
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "house_system": house_system, "stations": stations,
"lat": lat, "lon": lon, "house_system": house_system,
"house_systems": house_systems, "stations": stations,
"zodiac": zodiac, "tables": tables}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try:
@@ -92,7 +94,8 @@ def chart_compute(
ctx["moment"] = label
ctx["result"] = logic.positions(
when_utc_iso=iso_utc, lat=lat, lon=lon,
house_system=house_system, stations=stations, zodiac=zodiac, tables=tables,
house_system=house_system, house_systems=house_systems,
stations=stations, zodiac=zodiac, tables=tables,
)
from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera
ctx["wheel_svg"] = chartwheel.render(ctx["result"])
@@ -125,24 +128,32 @@ def compile_build(
lat: float = Form(0.0),
lon: float = Form(0.0),
house_system: str = Form("whole_sign"),
house_systems: list[str] = Form([]), # PRE-05 — porównanie domów w podsumowaniu
stations: bool = Form(False), # LOG-03 — stacje też w podsumowaniu
zodiac: str = Form("tropical"),
tables: bool = Form(False), # LOG-23 — żywioły/faza/godziny w podsumowaniu
):
"""Składa raport: horoskop liczymy TU NA NOWO, a części od AI (interpretacja
natalna i predykcje okresowe) dokłada przeglądarka z magazynu (PRE-22/23).
Dlaczego horoskop liczymy ponownie, zamiast go zapamiętywać: to czysta funkcja
danych wejściowych — tanio ją powtórzyć, a odpada trzymanie w przeglądarce
dużego wyniku, który mógłby się rozjechać z aktualnym formularzem.
Horoskop liczymy ponownie (czysta funkcja wejścia — tanio powtórzyć, bez
trzymania dużego wyniku w przeglądarce), ale z TYMI SAMYMI opcjami, które
wybrano przy horoskopie (stacje, tabele, porównanie domów) — inaczej
podsumowanie miałoby braki względem tego, co policzono. Opcje wędrują między
zakładkami przez formsync.
"""
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "house_system": house_system, "zodiac": zodiac}
"lat": lat, "lon": lon, "house_system": house_system,
"house_systems": house_systems, "stations": stations,
"zodiac": zodiac, "tables": tables}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
ctx["result"] = logic.positions(
when_utc_iso=iso_utc, lat=lat, lon=lon,
house_system=house_system, zodiac=zodiac,
house_system=house_system, house_systems=house_systems,
stations=stations, zodiac=zodiac, tables=tables,
)
from app import chartwheel
ctx["wheel_svg"] = chartwheel.render(ctx["result"])
@@ -186,6 +197,8 @@ def compile_pdf(payload: dict):
when_utc_iso=iso_utc,
lat=float(data.get("lat") or 0.0), lon=float(data.get("lon") or 0.0),
house_system=str(data.get("house_system") or "whole_sign"),
house_systems=[s for s in (data.get("house_systems") or []) if s],
stations=bool(data.get("stations")), tables=bool(data.get("tables")),
zodiac=str(data.get("zodiac") or "tropical"),
)
from app import chartwheel
@@ -442,6 +455,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"}
@@ -93,10 +93,23 @@
var el = document.querySelector('[name=' + name + ']');
return el ? el.value : '';
}
function checked(name) {
var el = document.querySelector('[name=' + name + ']');
return !!(el && el.checked);
}
function multi(name) {
var out = [];
document.querySelectorAll('[name=' + name + ']:checked').forEach(function (el) { out.push(el.value); });
return out;
}
// Opcje (stacje, tabele, porównanie domów) MUSZĄ jechać do PDF-a — inaczej
// PDF liczyłby okrojony horoskop i miałby braki względem podsumowania.
return {
date: v('date'), time: v('time'), tz_offset: v('tz_offset'),
lat: v('lat'), lon: v('lon'),
house_system: v('house_system'), zodiac: v('zodiac'),
house_systems: multi('house_systems'),
stations: checked('stations'), tables: checked('tables'),
place: (document.querySelector('#geoSearch') || {}).value || ''
};
}
+49 -22
View File
@@ -20,18 +20,31 @@
// Pola wspólne dla zakładek. Nazwa pola formularza -> jak je znaleźć.
// `geoSearch` (nazwa miejsca) nie ma atrybutu name — szukamy po id.
// type: brak = zwykła wartość (.value); 'check' = pojedynczy checkbox (.checked);
// 'multi' = kilka checkboxów o tej samej nazwie (lista zaznaczonych wartości).
// Opcje (stacje, tabele, porównanie domów) są tu, żeby wybór z „Horoskopu"
// wędrował na „Skompiluj" — inaczej podsumowanie liczyłoby się bez nich.
var FIELDS = [
{ key: 'person', sel: 'input[name=person]' },
{ key: 'date', sel: 'input[name=date]' },
{ key: 'time', sel: 'input[name=time]' },
{ key: 'tz_offset', sel: 'input[name=tz_offset]' },
{ key: 'lat', sel: 'input[name=lat]' },
{ key: 'lon', sel: 'input[name=lon]' },
{ key: 'house_system', sel: 'select[name=house_system]' },
{ key: 'zodiac', sel: 'select[name=zodiac]' },
{ key: 'place', sel: '#geoSearch' }
{ key: 'person', sel: 'input[name=person]' },
{ key: 'date', sel: 'input[name=date]' },
{ key: 'time', sel: 'input[name=time]' },
{ key: 'tz_offset', sel: 'input[name=tz_offset]' },
{ key: 'lat', sel: 'input[name=lat]' },
{ key: 'lon', sel: 'input[name=lon]' },
{ key: 'house_system', sel: 'select[name=house_system]' },
{ key: 'zodiac', sel: 'select[name=zodiac]' },
{ key: 'place', sel: '#geoSearch' },
{ key: 'stations', sel: 'input[name=stations]', type: 'check' },
{ key: 'tables', sel: 'input[name=tables]', type: 'check' },
{ key: 'house_systems', name: 'house_systems', type: 'multi' }
];
function elementsOf(f) {
if (f.type === 'multi') return Array.prototype.slice.call(document.querySelectorAll('[name=' + f.name + ']'));
var el = document.querySelector(f.sel);
return el ? [el] : [];
}
function load() {
try {
return JSON.parse(localStorage.getItem(KEY) || '{}') || {};
@@ -50,21 +63,35 @@
function apply(state) {
FIELDS.forEach(function (f) {
var el = document.querySelector(f.sel);
if (!el) return;
var els = elementsOf(f);
if (!els.length) return;
var v = state[f.key];
if (v === undefined || v === null || v === '') return;
if (el.value !== v) el.value = v;
if (v === undefined || v === null) return;
if (f.type === 'multi') {
if (!Array.isArray(v)) return;
els.forEach(function (el) { el.checked = v.indexOf(el.value) !== -1; });
} else if (f.type === 'check') {
els[0].checked = !!v;
} else {
if (v === '') return;
if (els[0].value !== v) els[0].value = v;
}
});
}
function collect() {
var state = load();
FIELDS.forEach(function (f) {
var el = document.querySelector(f.sel);
if (!el) return; // pola nieobecnego na tej zakładce nie kasujemy
if (el.value === '' || el.value === null) return;
state[f.key] = el.value;
var els = elementsOf(f);
if (!els.length) return; // pola nieobecnego na tej zakładce nie kasujemy
if (f.type === 'multi') {
state[f.key] = els.filter(function (el) { return el.checked; }).map(function (el) { return el.value; });
} else if (f.type === 'check') {
state[f.key] = els[0].checked;
} else {
if (els[0].value === '' || els[0].value === null) return;
state[f.key] = els[0].value;
}
});
return state;
}
@@ -72,12 +99,12 @@
// 1) odtwórz to, co użytkownik wpisał gdzie indziej
apply(load());
// 2) zapisuj każdą zmianę (input dla wpisywania, change dla list i kalendarzy)
// 2) zapisuj każdą zmianę (input dla wpisywania, change dla list, checkboxów, kalendarzy)
FIELDS.forEach(function (f) {
var el = document.querySelector(f.sel);
if (!el) return;
['input', 'change'].forEach(function (ev) {
el.addEventListener(ev, function () { save(collect()); });
elementsOf(f).forEach(function (el) {
['input', 'change'].forEach(function (ev) {
el.addEventListener(ev, function () { save(collect()); });
});
});
});
+47
View File
@@ -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();
});
})();
@@ -0,0 +1,232 @@
{# Tabele wyniku horoskopu — używane przez „Skompiluj" (/compile), żeby
podsumowanie pokazywało dokładnie to, co policzono (regresja 2026-07-28:
podsumowanie miało braki). Skopiowane 1:1 z sekcji tabel na „Horoskop" (/).
TODO: przełączyć też chart.html na ten include, żeby oba widoki nie mogły się
rozjechać — na razie pilnować ręcznie, że są zgodne. Rysunki (koło/aspektarian/
deklinacja/antyscja) renderuje rodzic; tu są same tabele. Każda sekcja pokazuje
się tylko, gdy jej dane są w wyniku (opcja policzona → sekcja jest). #}
{% if result.angles %}
<table class="angles">
<thead><tr><th></th><th>Znak</th><th>W znaku</th></tr></thead>
<tbody>
{% for key in ["Asc", "MC", "Dsc", "IC"] %}
{% set a = result.angles[key] %}
<tr><td>{{ a.name }}</td><td>{{ a.sign }}</td><td class="mono">{{ a.in_sign }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{# Porównanie systemów domów (PRE-05) — te same osie, inny podział na domy #}
{% if result.house_systems and result.house_systems | length > 1 %}
{% set HSN = {'whole_sign': 'Whole Sign', 'equal': 'Equal', 'porphyry': 'Porphyry'} %}
<div class="meta" title="Osie (Asc/MC) są wspólne — różni się podział na domy. Kosmogram rysuje pierwszy z listy.">Porównanie systemów domów ({{ result.house_systems | length }})</div>
<table class="angles">
<thead><tr><th>Dom</th>{% for hs in result.house_systems %}<th>{{ HSN.get(hs.system, hs.system) }}</th>{% endfor %}</tr></thead>
<tbody>
{% for i in range(12) %}
<tr><td>{{ i + 1 }}</td>
{% for hs in result.house_systems %}
<td class="mono"><span class="glyph">{{ hs.cusps[i].sign_glyph }}</span> {{ hs.cusps[i].in_sign }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<table>
<thead>
<tr>
<th title="Symbol astrologiczny (LOG-22)">Sym.</th>
<th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Kier.</th><th>Prędkość °/d</th>
<th title="Deklinacja — odległość od równika niebieskiego. OOB = poza zakresem Słońca">Dekl.</th>
</tr>
</thead>
<tbody>
{% for p in result.positions %}
<tr>
<td class="glyph">{{ p.glyph or '' }}{% if p.sign_glyph %} {{ p.sign_glyph }}{% endif %}</td>
<td>{{ p.name }}</td>
<td>{{ p.sign }}</td>
<td class="mono">{{ p.in_sign }}</td>
<td>{{ p.house if p.house is defined else '—' }}</td>
<td class="{{ 'retro' if p.direction == 'Rx' else '' }}">{{ p.direction }}</td>
<td class="mono">{{ '%+.4f' | format(p.speed) }}</td>
<td class="mono">{% if p.declination is defined %}{{ '%+.2f°' | format(p.declination) }}{% if p.out_of_bounds %} <span class="badge" title="Out of bounds — deklinacja poza zakresem Słońca">OOB</span>{% endif %}{% else %}—{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if result.lots %}
<div class="meta">Lots hermetyczne ({{ result.lots | length }}) · sekta: <strong>{{ result.sect }}</strong></div>
<table class="angles">
<thead><tr><th>Lot</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Formuła</th></tr></thead>
<tbody>
{% for l in result.lots %}
<tr><td>{{ l.name }}</td><td>{{ l.sign }}</td><td class="mono">{{ l.in_sign }}</td><td>{{ l.house }}</td><td class="muted small">{{ l.formula }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% set with_stations = result.positions | selectattr('stations', 'defined') | list %}
{% if with_stations %}
<div class="meta">Stacje planet (poprzednia / następna; <span class="badge">blisko</span> = mniej niż 7 dni)</div>
<table class="angles">
<thead><tr><th>Planeta</th><th>Poprzednia</th><th>Następna</th></tr></thead>
<tbody>
{% for p in with_stations %}
<tr>
<td>{{ p.name }}{% if p.stations.station_soon %} <span class="badge">blisko</span>{% endif %}</td>
<td class="mono">{% if p.stations.prev %}{{ p.stations.prev.type }} · {{ p.stations.prev.date }} · {{ p.stations.prev.degree }} ({{ p.stations.prev.days }} d){% else %}—{% endif %}</td>
<td class="mono">{% if p.stations.next %}{{ p.stations.next.type }} · {{ p.stations.next.date }} · {{ p.stations.next.degree }} (+{{ p.stations.next.days }} d){% else %}—{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if result.aspects %}
<div class="meta">Aspekty główne ({{ result.aspects | length }})</div>
<table class="angles">
<thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th><th title="A = aplikacyjny (dokładność nastąpi), S = separacyjny (już minęła)">A/S</th></tr></thead>
<tbody>
{% for a in result.aspects %}
<tr><td>{{ a.obj1 }}</td><td>{% if a.glyph %}<span class="glyph">{{ a.glyph }}</span> {% endif %}{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td><td>{{ a['as'] if a['as'] is defined else '—' }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if result.parallels %}
<div class="meta" title="Ciała na tej samej (paralela) lub przeciwnej (kontrparalela) deklinacji — działają jak koniunkcja / opozycja poza ekliptyką">Paralele deklinacji ({{ result.parallels | length }})</div>
<table class="angles">
<thead><tr><th>Obiekt 1</th><th>Rodzaj</th><th>Obiekt 2</th><th>Orb</th><th>Dekl.</th></tr></thead>
<tbody>
{% for r in result.parallels %}
<tr>
<td>{{ r.obj1 }}</td>
<td>{{ 'paralela' if r.type == 'parallel' else 'kontrparalela' }}</td>
<td>{{ r.obj2 }}</td>
<td class="mono">{{ '%.2f'|format(r.orb) }}°</td>
<td class="mono">{{ '%+.2f'|format(r.dec1) }} / {{ '%+.2f'|format(r.dec2) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if result.antiscia %}
<div class="meta" title="Odbicie względem osi przesileń (antyscja) lub równonocy (kontrantyscja) — punkty „dzielące” tę samą długość dnia">Antyscja ({{ result.antiscia | length }})</div>
<table class="angles">
<thead><tr><th>Obiekt 1</th><th>Rodzaj</th><th>Obiekt 2</th><th>Orb</th></tr></thead>
<tbody>
{% for r in result.antiscia %}
<tr>
<td>{{ r.obj1 }}</td>
<td>{{ 'antyscja' if r.type == 'antiscion' else 'kontrantyscja' }}</td>
<td>{{ r.obj2 }}</td>
<td class="mono">{{ '%.2f'|format(r.orb) }}°</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if result.tables %}
{% set tb = result.tables %}
<div class="meta">Tabele dodatkowe (LOG-23)</div>
{% set bal = tb.tally.with_modern_10_plus_asc %}
<table class="angles">
<thead><tr><th>Bilans (10 planet + Asc)</th><th colspan="4">Rozkład</th></tr></thead>
<tbody>
<tr><td>Żywioły</td>
{% for e, n in bal.elements.items() %}
<td>{{ tb.tally.labels.elements[e] }}: <strong>{{ n }}</strong></td>
{% endfor %}
</tr>
<tr><td>Jakości</td>
{% for q, n in bal.qualities.items() %}
<td>{{ tb.tally.labels.qualities[q] }}: <strong>{{ n }}</strong></td>
{% endfor %}
<td></td>
</tr>
</tbody>
</table>
{% if tb.tally.missing_elements or tb.tally.missing_qualities %}
<p class="muted small">Brak:
{% for e in tb.tally.missing_elements %}<strong>{{ tb.tally.labels.elements[e] }}</strong>{{ ", " if not loop.last }}{% endfor %}
{% for q in tb.tally.missing_qualities %}<strong>{{ tb.tally.labels.qualities[q] }}</strong>{{ ", " if not loop.last }}{% endfor %}
</p>
{% endif %}
{% if tb.moon_phase %}
<p class="muted small">
<strong>Faza Księżyca:</strong> {{ tb.moon_phase.phase_pl }} ·
elongacja {{ '%.2f'|format(tb.moon_phase.angle) }}° ·
oświetlenie {{ '%.1f'|format(tb.moon_phase.illumination * 100) }}% ·
{{ 'przybywa' if tb.moon_phase.waxing else 'ubywa' }}
</p>
{% endif %}
{% if tb.planetary_hours %}
{% set ph = tb.planetary_hours %}
<p class="muted small">
<strong>Godziny planetarne:</strong> władca dnia {{ ph.day_ruler }} ·
{{ ph.hour_number }}. godzina ({{ ph.period }}), władca {{ ph.hour_ruler }} ·
godzina trwa {{ ph.hour_length_minutes }} min
</p>
{% endif %}
{% if tb.prenatal_syzygy %}
{% set s = tb.prenatal_syzygy %}
<p class="muted small">
<strong>Syzygia prenatalna:</strong> {{ s.type_pl }} {{ s.when_utc }}
({{ s.days_before_birth }} dni przed) w {{ s.in_sign }}
</p>
{% endif %}
{% if tb.critical_degrees %}
<div class="meta">Stopnie krytyczne</div>
<table class="angles">
<thead><tr><th>Obiekt</th><th>Pozycja</th><th>Uwaga</th></tr></thead>
<tbody>
{% for c in tb.critical_degrees %}
<tr><td>{{ c.name }}</td><td class="mono">{{ c.in_sign }}</td>
<td class="muted small">{{ c.flags | join('; ') }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<details class="loc">
<summary>Podziały: dwunastniki (D12) i nawamsa (D9)</summary>
<table>
<thead><tr><th>Obiekt</th><th>D12</th><th>D9</th></tr></thead>
<tbody>
{% for d in tb.divisional %}
<tr><td>{{ d.name }}</td><td class="mono">{{ d.d12_in_sign }}</td>
<td class="mono">{{ d.d9_in_sign }}</td></tr>
{% endfor %}
</tbody>
</table>
</details>
{% endif %}
{% if result.cusps %}
<details class="loc">
<summary>Cusps domów ({{ result.house_system }})</summary>
<table>
<thead><tr><th>Dom</th><th>Znak</th><th>Cusp</th></tr></thead>
<tbody>
{% for c in result.cusps %}
<tr><td>{{ c.house }}</td><td>{{ c.sign }}</td><td class="mono">{{ c.in_sign }}</td></tr>
{% endfor %}
</tbody>
</table>
</details>
{% endif %}
@@ -57,6 +57,13 @@
<label><input type="checkbox" name="tables" value="true" {{ 'checked' if form.tables else '' }}>
tabele dodatkowe: żywioły, faza Księżyca, godziny planetarne (wolniejsze)</label>
</div>
<div class="opts" title="Domy dzielą to samo niebo inaczej. Zaznacz kilka, by porównać kuspy obok siebie (PRE-05). Kosmogram rysuje system wybrany wyżej.">
{% set chosen = form.house_systems or [] %}
<span class="muted small">Porównaj systemy domów:</span>
<label><input type="checkbox" name="house_systems" value="whole_sign" {{ 'checked' if 'whole_sign' in chosen else '' }}> Whole Sign</label>
<label><input type="checkbox" name="house_systems" value="equal" {{ 'checked' if 'equal' in chosen else '' }}> Equal</label>
<label><input type="checkbox" name="house_systems" value="porphyry" {{ 'checked' if 'porphyry' in chosen else '' }}> Porphyry</label>
</div>
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
<div class="actions">
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
@@ -104,6 +111,24 @@
</table>
{% endif %}
{# Porównanie systemów domów (PRE-05) — te same osie, inny podział na domy #}
{% if result.house_systems and result.house_systems | length > 1 %}
{% set HSN = {'whole_sign': 'Whole Sign', 'equal': 'Equal', 'porphyry': 'Porphyry'} %}
<div class="meta" title="Osie (Asc/MC) są wspólne — różni się podział na domy. Kosmogram rysuje pierwszy z listy.">Porównanie systemów domów ({{ result.house_systems | length }})</div>
<table class="angles">
<thead><tr><th>Dom</th>{% for hs in result.house_systems %}<th>{{ HSN.get(hs.system, hs.system) }}</th>{% endfor %}</tr></thead>
<tbody>
{% for i in range(12) %}
<tr><td>{{ i + 1 }}</td>
{% for hs in result.house_systems %}
<td class="mono"><span class="glyph">{{ hs.cusps[i].sign_glyph }}</span> {{ hs.cusps[i].in_sign }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<table>
<thead>
<tr>
@@ -55,6 +55,21 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
</select>
</label>
</div>
{# Te same opcje co na „Horoskop" — synchronizowane między zakładkami (formsync),
żeby podsumowanie liczyło się z tym, co wybrano przy horoskopie (nie okrojone). #}
<div class="opts">
<label><input type="checkbox" name="stations" value="true" {{ 'checked' if form.stations else '' }}>
licz stacje planet (wolniejsze)</label>
<label><input type="checkbox" name="tables" value="true" {{ 'checked' if form.tables else '' }}>
tabele dodatkowe: żywioły, faza Księżyca, godziny planetarne (wolniejsze)</label>
</div>
<div class="opts">
{% set chosen = form.house_systems or [] %}
<span class="muted small">Porównaj systemy domów:</span>
<label><input type="checkbox" name="house_systems" value="whole_sign" {{ 'checked' if 'whole_sign' in chosen else '' }}> Whole Sign</label>
<label><input type="checkbox" name="house_systems" value="equal" {{ 'checked' if 'equal' in chosen else '' }}> Equal</label>
<label><input type="checkbox" name="house_systems" value="porphyry" {{ 'checked' if 'porphyry' in chosen else '' }}> Porphyry</label>
</div>
<div class="actions">
<button type="submit">Złóż podsumowanie</button>
<button type="button" id="pdfBtn" class="ghost"
@@ -106,34 +121,12 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
{% endif %}
{# ── 3. Dane policzone ────────────────────────────────────────────── #}
{# WSPÓLNE tabele (ten sam plik co „Horoskop") — podsumowanie pokazuje dokładnie
to, co policzono: porównanie domów, stacje, żywioły itd., jeśli były wybrane. #}
<div class="meta">Horoskop · silnik {{ result.engine }}
{% if result.house_system %}· domy {{ result.house_system }}{% endif %}
{% if result.zodiac %}· zodiak {{ result.zodiac }}{% endif %}</div>
<table>
<thead><tr><th>Sym.</th><th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Kier.</th></tr></thead>
<tbody>
{% for p in result.positions %}
<tr>
<td class="glyph">{{ p.glyph or '' }}{% if p.sign_glyph %} {{ p.sign_glyph }}{% endif %}</td>
<td>{{ p.name }}</td><td>{{ p.sign }}</td><td class="mono">{{ p.in_sign }}</td>
<td>{{ p.house if p.house is defined else '—' }}</td>
<td class="{{ 'retro' if p.direction == 'Rx' else '' }}">{{ p.direction }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if result.angles %}
<table class="angles">
<thead><tr><th></th><th>Znak</th><th>W znaku</th></tr></thead>
<tbody>
{% for key in ["Asc", "MC", "Dsc", "IC"] %}
{% set a = result.angles[key] %}
<tr><td>{{ a.name }}</td><td>{{ a.sign }}</td><td class="mono">{{ a.in_sign }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% include "_result_tables.html" %}
{# ── 4. i 5. Części od AI — wstawia przeglądarka z magazynu ───────── #}
<div id="reportNatal"></div>
+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 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,5 +5,10 @@ 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
# Eksport wyników do Excela — „tabela robocza" (DAN-23/PRE-10)
openpyxl>=3.1
@@ -159,3 +159,36 @@ def test_ai_text_is_escaped_before_injection():
def test_does_nothing_on_other_tabs():
assert "if (!document.getElementById('readiness')) return;" in JS
# ─────── podsumowanie NIE gubi opcji policzonych przy horoskopie ──────────
# Regresja 2026-07-28: „Skompiluj" przeliczało horoskop bez stacji/tabel/
# porównania domów, więc podsumowanie miało braki względem tego, co policzono.
def test_compile_form_carries_the_options():
"""Te same opcje co „Horoskop" (synchronizowane) — inaczej nie da się ich przekazać."""
for f in ('name="stations"', 'name="tables"', 'name="house_systems"'):
assert f in TPL, f"brak opcji w formularzu podsumowania: {f}"
def test_compile_handler_recomputes_with_all_options():
assert "house_systems: list[str] = Form([])" in MAIN
assert "stations: bool = Form(False)" in MAIN
assert "house_systems=house_systems" in MAIN # przekazane do logiki
assert "stations=stations, zodiac=zodiac, tables=tables" in MAIN
def test_compile_pdf_recomputes_with_all_options():
assert 'stations=bool(data.get("stations"))' in MAIN
assert 'house_systems=[s for s in (data.get("house_systems")' in MAIN
def test_compile_pdf_payload_includes_options():
assert "house_systems: multi('house_systems')" in JS
assert "stations: checked('stations')" in JS
def test_summary_uses_shared_result_tables():
"""Podsumowanie pokazuje te same tabele co horoskop — jeden wspólny plik,
żeby nie mogły znowu się rozjechać."""
assert '{% include "_result_tables.html" %}' in TPL
+11 -3
View File
@@ -20,7 +20,15 @@ WITH_FORM = ("chart.html", "interpret.html", "timeline.html")
# Pola, które mają wędrować między zakładkami. Gdy dojdzie nowe wspólne pole,
# ten wykaz ma się nie rozjechać z formsync.js.
SHARED = ("person", "date", "time", "tz_offset", "lat", "lon",
"house_system", "zodiac", "place")
"house_system", "zodiac", "place",
"stations", "tables", "house_systems") # opcje też wędrują (regresja: podsumowanie liczyło się bez nich)
def test_option_checkboxes_are_synced_by_type():
"""Stacje/tabele to checkboxy (.checked), a porównanie domów to wiele checkboxów
tej samej nazwy (lista) formsync musi umieć oba, nie tylko .value."""
assert "'check'" in SYNC_JS and "'multi'" in SYNC_JS
assert "el.checked" in SYNC_JS
# ─────────────────────────────── pole imienia ────────────────────────────
@@ -90,5 +98,5 @@ def test_map_changes_are_reported_to_sync():
def test_sync_does_not_wipe_fields_missing_on_a_tab():
"""Zakładka bez danego pola (np. Sygnifikatory) nie może wyczyścić wartości
zapamiętanej z innej zakładki."""
assert "if (!el) return;" in SYNC_JS
zapamiętanej z innej zakładki. `collect` pomija pole, którego nie ma na stronie."""
assert "if (!els.length) return;" in SYNC_JS
@@ -0,0 +1,43 @@
"""Porównanie systemów domów naraz (PRE-05).
JS-a tu nie ma sprawdzamy strukturalnie: formularz pozwala zaznaczyć kilka
systemów, klient przekazuje je do logiki, a szablon pokazuje kuspy obok siebie.
"""
import pathlib
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
CHART = (APP / "templates" / "chart.html").read_text(encoding="utf-8")
MAIN = (APP / "main.py").read_text(encoding="utf-8")
CLIENT = (APP / "clients" / "logic_client.py").read_text(encoding="utf-8")
def test_form_lets_you_pick_extra_systems():
"""Checkboxy dla trzech systemów — wielokrotny wybór (name powtórzony)."""
for val in ("whole_sign", "equal", "porphyry"):
assert f'name="house_systems" value="{val}"' in CHART, f"brak checkboxa {val}"
def test_handler_accepts_and_passes_house_systems():
assert "house_systems: list[str] = Form([])" in MAIN
assert "house_systems=house_systems" in MAIN # przekazane do logiki
def test_client_forwards_house_systems_to_logic():
assert '"house_systems": house_systems' in CLIENT
def test_comparison_table_renders_only_for_multiple_systems():
"""Bez dodatkowych systemów nie pokazujemy pustej tabeli porównania."""
assert "result.house_systems | length > 1" in CHART
assert "Porównanie systemów domów" in CHART
def test_comparison_table_shows_cusp_per_system():
"""Tabela iteruje po systemach i po 12 domach — kusp w każdej komórce."""
assert "for hs in result.house_systems" in CHART
assert "hs.cusps[i].in_sign" in CHART
def test_checkbox_state_survives_submit():
"""Zaznaczone systemy zostają zaznaczone po przeliczeniu."""
assert "in chosen" in CHART and "form.house_systems" in CHART
@@ -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ę
@@ -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