diff --git a/docs/astrololo_wymagania.xlsx b/docs/astrololo_wymagania.xlsx index 707ecd1..0536647 100644 Binary files a/docs/astrololo_wymagania.xlsx and b/docs/astrololo_wymagania.xlsx differ diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py index ba46bd9..18d1393 100644 --- a/services/presentation/app/main.py +++ b/services/presentation/app/main.py @@ -102,6 +102,53 @@ def chart_compute( return templates.TemplateResponse(request, "chart.html", ctx) +# ---------------- Skompiluj: zbiorczy raport (PRE-23) ---------------- +@app.get("/compile", response_class=HTMLResponse) +def compile_form(request: Request): + return templates.TemplateResponse( + request, "compile.html", + {"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL}, + ) + + +@app.post("/compile", response_class=HTMLResponse) +def compile_build( + request: Request, + person: str = Form(""), + date: str = Form(...), + time: str = Form(...), + tz_offset: float = Form(0.0), + lat: float = Form(0.0), + lon: float = Form(0.0), + house_system: str = Form("whole_sign"), + zodiac: str = Form("tropical"), +): + """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. + """ + form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset, + "lat": lat, "lon": lon, "house_system": house_system, "zodiac": zodiac} + 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, + ) + from app import chartwheel + ctx["wheel_svg"] = chartwheel.render(ctx["result"]) + except (httpx.HTTPError,) as e: + ctx["error"] = _logic_error(e) + except ValueError as e: + ctx["error"] = f"Niepoprawne dane wejściowe: {e}" + return templates.TemplateResponse(request, "compile.html", ctx) + + # ---------------- Sygnifikatory (wyszukiwarka w bazach) ---------------- @app.get("/significators", response_class=HTMLResponse) def significators_form(request: Request): diff --git a/services/presentation/app/static/compile.js b/services/presentation/app/static/compile.js new file mode 100644 index 0000000..8167bf3 --- /dev/null +++ b/services/presentation/app/static/compile.js @@ -0,0 +1,95 @@ +// Zakładka „Skompiluj" — składanie raportu (PRE-23). +// +// Raport ma trzy kawałki liczone w różnych miejscach: +// 1. horoskop — liczy serwer przy wysłaniu tego formularza, +// 2. interpretacja natalna — z zakładki Interpretacje (natal.js), +// 3. predykcje okresowe — z zakładki Kalendarz (predictions.js). +// +// Punkty 2 i 3 mieszkają w magazynie przeglądarki, więc to tutaj je dokładamy. +// Odczytujemy je przez API tamtych modułów, a nie sięgając wprost do +// localStorage — dzięki temu format danych ma JEDNEGO właściciela. +// +// Pokazujemy też, CZEGO BRAKUJE. Bez tego użytkownik złożyłby niekompletny raport +// i dowiedział się o tym dopiero po otwarciu PDF-a. +(function () { + 'use strict'; + + function esc(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>'); + } + + function readNatal() { + return (window.astrololoNatal && window.astrololoNatal.read()) || null; + } + + function readPredictions() { + return (window.astrololoPredictions && window.astrololoPredictions.read()) || []; + } + + function item(ok, label, hint) { + return '
  • ' + + (ok ? '✓ ' : '✗ ') + esc(label) + + (ok ? '' : ' — ' + esc(hint) + '') + '
  • '; + } + + function renderReadiness(hasChart) { + var host = document.getElementById('readiness'); + if (!host) return; + var natal = readNatal(); + var preds = readPredictions(); + var parts = [ + item(hasChart, 'Policzony horoskop', 'wypełnij dane i kliknij „Złóż podsumowanie"'), + item(!!natal, 'Interpretacja natalna', + 'wygeneruj ją na zakładce Interpretacje — zapamięta się sama'), + item(preds.length > 0, + 'Predykcje okresowe' + (preds.length ? ' (' + preds.length + ')' : ''), + 'policz horoskop na wybrany okres w Kalendarzu') + ]; + host.innerHTML = '
    Materiał do raportu
    '; + } + + function renderNatal() { + var host = document.getElementById('reportNatal'); + if (!host) return; + var natal = readNatal(); + if (!natal) { + host.innerHTML = '
    Interpretacja natalna
    ' + + '

    Brak — wygeneruj ją na zakładce Interpretacje.

    '; + return; + } + host.innerHTML = '
    Interpretacja natalna ' + + '· zapamiętana ' + esc(String(natal.saved_at).slice(0, 10)) + + '
    ' + esc(natal.text) + '
    '; + } + + function renderPredictions() { + var host = document.getElementById('reportPredictions'); + if (!host) return; + var preds = readPredictions(); + if (!preds.length) { + host.innerHTML = '
    Predykcje okresowe
    ' + + '

    Brak — policz horoskop na wybrany okres w Kalendarzu.

    '; + return; + } + var blocks = preds.map(function (p) { + return '
    ' + esc(p.from_date) + ' – ' + esc(p.to_date) + '
    ' + + '
    ' + esc(p.text) + '
    '; + }).join(''); + host.innerHTML = '
    Predykcje okresowe (' + preds.length + ')
    ' + blocks; + } + + function ready(fn) { + if (document.readyState !== 'loading') fn(); + else document.addEventListener('DOMContentLoaded', fn); + } + + ready(function () { + if (!document.getElementById('readiness')) return; // nie ta zakładka + // Horoskop jest w dokumencie tylko po wysłaniu formularza. + renderReadiness(!!document.getElementById('reportPerson')); + renderNatal(); + renderPredictions(); + }); +})(); diff --git a/services/presentation/app/static/natal.js b/services/presentation/app/static/natal.js new file mode 100644 index 0000000..10abacb --- /dev/null +++ b/services/presentation/app/static/natal.js @@ -0,0 +1,97 @@ +// Zapamiętana interpretacja natalna (PRE-23). +// +// Odpowiednik predictions.js, tylko że natalna jest JEDNA — dotyczy momentu +// urodzenia, nie wybranego okresu. Ponowne wygenerowanie podmienia poprzednią. +// +// Po co magazyn: zakładka „Skompiluj" składa raport z trzech kawałków liczonych +// w różnych miejscach (horoskop, interpretacja natalna, predykcje okresowe). +// Bez zapamiętania trzeba by je generować od nowa na jednej stronie. +// +// localStorage, tak jak reszta stanu (PRE-21/22) — serwer zostaje bezstanowy. +(function () { + 'use strict'; + + var KEY = 'astrololo.natal.v1'; + + function read() { + try { + var v = JSON.parse(localStorage.getItem(KEY) || 'null'); + return (v && typeof v === 'object' && v.text) ? v : null; + } catch (e) { + return null; // uszkodzony wpis nie może zablokować strony + } + } + + function clear() { + try { localStorage.removeItem(KEY); } catch (e) { /* trudno */ } + } + + function val(sel) { + var el = document.querySelector(sel); + return el ? el.value.trim() : ''; + } + + function save() { + var text = val('#horoscopeText'); + if (!text) return false; // nie ma czego zapamiętać + var entry = { + text: text, + person: val('input[name=person]'), + date: val('input[name=date]'), + time: val('input[name=time]'), + tz_offset: val('input[name=tz_offset]'), + lat: val('input[name=lat]'), + lon: val('input[name=lon]'), + saved_at: new Date().toISOString() + }; + try { + localStorage.setItem(KEY, JSON.stringify(entry)); + return true; + } catch (e) { + return false; // przepełniony magazyn — zgłaszamy wyżej + } + } + + function note(msg) { + var el = document.getElementById('natalNote'); + if (el) el.textContent = msg; + } + + function saveCurrent() { + var text = val('#horoscopeText'); + if (!text) return; + if (save()) { + note('Interpretacja natalna zapamiętana — trafi do raportu (zakładka Skompiluj).'); + } else { + // Interpretacje bywają długie; cicha strata byłaby najgorsza. + note('Nie udało się zapamiętać — magazyn przeglądarki pełny. ' + + 'Usuń starsze predykcje i spróbuj ponownie.'); + } + } + + function ready(fn) { + if (document.readyState !== 'loading') fn(); + else document.addEventListener('DOMContentLoaded', fn); + } + + ready(function () { + if (!document.getElementById('natalNote')) return; // nie ta zakładka + + // 1) interpretacja policzona przez okno postępu (strumień) + document.addEventListener('astrololo:horoscope', function (e) { + if (!e.detail || e.detail.profile === 'natal') saveCurrent(); + }); + + // 2) wariant bez strumienia: strona wróciła z gotowym wynikiem po zwykłym + // POST. Zapis nadpisuje jeden slot, więc odświeżenie niczego nie mnoży. + if (document.querySelector('#horoscopeText')) saveCurrent(); + + var status = read(); + if (status) { + note('W raporcie jest zapamiętana interpretacja natalna z ' + + String(status.saved_at).slice(0, 10) + '.'); + } + }); + + window.astrololoNatal = { read: read, clear: clear, key: KEY }; +})(); diff --git a/services/presentation/app/static/styles.css b/services/presentation/app/static/styles.css index 85ad0f0..467aac5 100644 --- a/services/presentation/app/static/styles.css +++ b/services/presentation/app/static/styles.css @@ -152,3 +152,15 @@ textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; } .progress-log li.log-ok { color: #7fd18b; } .progress-log li.log-warn { color: #e0c060; } .progress-log li.log-err { color: #ef6b6b; } + +/* Zakładka „Skompiluj" (PRE-23) — składanie raportu z trzech źródeł. */ +.readiness { list-style: none; margin: .3rem 0 1rem; padding: 0; } +.readiness li { padding: .2rem 0; } +.readiness .rdy-ok { color: #7fd18b; } +.readiness .rdy-missing { color: #e0c060; } +.report-head { margin: 1.5rem 0 .5rem; } +.report-head h2 { margin: 0; font-size: 1.4rem; } +/* Treść od AI bywa długa — white-space:pre-wrap zachowuje akapity modelu, + bez przepisywania ich na HTML. */ +.report-text { white-space: pre-wrap; background: var(--panel); border: 1px solid var(--line); + border-radius: 10px; padding: .8rem 1rem; margin-top: .4rem; line-height: 1.6; } diff --git a/services/presentation/app/templates/base.html b/services/presentation/app/templates/base.html index 3bfb405..71ec1e2 100644 --- a/services/presentation/app/templates/base.html +++ b/services/presentation/app/templates/base.html @@ -23,6 +23,7 @@ Interpretacje Kalendarz Sygnifikatory + Skompiluj {% block content %}{% endblock %} diff --git a/services/presentation/app/templates/compile.html b/services/presentation/app/templates/compile.html new file mode 100644 index 0000000..45a18f3 --- /dev/null +++ b/services/presentation/app/templates/compile.html @@ -0,0 +1,124 @@ +{% extends "base.html" %} +{% block title %}Skompiluj{% endblock %} +{% block nav_compile %}active{% endblock %} + +{% block content %} +

    Składa w jedno: policzony horoskop, interpretację natalną z AI oraz wszystkie +zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie trzeba nic przepisywać.

    + +{# Gotowość materiału — wypełniana po stronie przeglądarki (compile.js), bo część + kawałków (AI) mieszka w magazynie lokalnym, nie na serwerze. #} +
    + +
    +
    + +
    +
    + + + +
    +
    + + + + +
    +
    + +
    +
    + +{% if error %}
    {{ error }}
    {% endif %} + +{% if result %} + {# ── 1. Nagłówek: kto ──────────────────────────────────────────────── #} +
    +

    {{ form.person or 'Bez podanego imienia' }}

    +

    + {{ form.date }} {{ form.time }} · offset {{ form.tz_offset }} h względem GMT · + {{ '%.4f'|format(form.lat|float) }}, {{ '%.4f'|format(form.lon|float) }} + {% if moment %}· {{ moment }}{% endif %} +

    +
    + + {# ── 2. Kosmogram ─────────────────────────────────────────────────── #} + {% if wheel_svg %} +
    + {{ wheel_svg | safe }} +
    Kosmogram — kliknij, aby powiększyć.
    +
    + {% endif %} + + {# ── 3. Dane policzone ────────────────────────────────────────────── #} +
    Horoskop · silnik {{ result.engine }} + {% if result.house_system %}· domy {{ result.house_system }}{% endif %} + {% if result.zodiac %}· zodiak {{ result.zodiac }}{% endif %}
    + + + + {% for p in result.positions %} + + + + + + + {% endfor %} + +
    Sym.ObiektZnakW znakuDomKier.
    {{ p.glyph or '' }}{% if p.sign_glyph %} {{ p.sign_glyph }}{% endif %}{{ p.name }}{{ p.sign }}{{ p.in_sign }}{{ p.house if p.house is defined else '—' }}{{ p.direction }}
    + + {% if result.angles %} + + + + {% for key in ["Asc", "MC", "Dsc", "IC"] %} + {% set a = result.angles[key] %} + + {% endfor %} + +
    ZnakW znaku
    {{ a.name }}{{ a.sign }}{{ a.in_sign }}
    + {% endif %} + + {# ── 4. i 5. Części od AI — wstawia przeglądarka z magazynu ───────── #} +
    +
    +{% endif %} + +{# Magazyny wczytujemy dla ich API odczytu. Ich własne UI samo się wyłącza — + każdy sprawdza, czy jego kontener jest na stronie (tu go nie ma). #} + + + +{% endblock %} diff --git a/services/presentation/app/templates/interpret.html b/services/presentation/app/templates/interpret.html index 620b8ed..07506b6 100644 --- a/services/presentation/app/templates/interpret.html +++ b/services/presentation/app/templates/interpret.html @@ -36,6 +36,7 @@ {% if location_label %}

    Wstępnie wpisano lokalizację: {{ location_label }} ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".

    {% endif %} {% include "_prompt_block.html" %} +

    @@ -99,4 +100,6 @@ +{# po progress.js — nasłuchuje zdarzenia o gotowej interpretacji (PRE-23) #} + {% endblock %} diff --git a/services/presentation/tests/test_compile.py b/services/presentation/tests/test_compile.py new file mode 100644 index 0000000..1992c44 --- /dev/null +++ b/services/presentation/tests/test_compile.py @@ -0,0 +1,134 @@ +"""Zakładka „Skompiluj" — zbiorczy raport (PRE-23). + +Raport składa się z trzech kawałków liczonych w różnych miejscach: horoskopu +(serwer), interpretacji natalnej i predykcji okresowych (magazyn przeglądarki). +Pilnujemy, żeby zakładka umiała je zebrać, a przede wszystkim — żeby POWIEDZIAŁA, +czego brakuje. Niekompletny raport, o którym dowiadujesz się dopiero z PDF-a, +byłby najgorszym wynikiem. +""" +import pathlib + +from starlette.testclient import TestClient + +APP = pathlib.Path(__file__).resolve().parents[1] / "app" +TPL = (APP / "templates" / "compile.html").read_text(encoding="utf-8") +BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8") +JS = (APP / "static" / "compile.js").read_text(encoding="utf-8") +NATAL_JS = (APP / "static" / "natal.js").read_text(encoding="utf-8") +INTERPRET = (APP / "templates" / "interpret.html").read_text(encoding="utf-8") +MAIN = (APP / "main.py").read_text(encoding="utf-8") + + +def _client(): + import os + + os.environ.pop("APP_PASSWORD", None) # bez bramki logowania w teście + from app.main import app + + return TestClient(app) + + +# ───────────────────────────────── nawigacja i trasa ───────────────────── + +def test_tab_is_in_the_menu(): + assert 'href="/compile"' in BASE and "Skompiluj" in BASE + + +def test_get_renders_empty_form(): + r = _client().get("/compile") + assert r.status_code == 200 + assert "Złóż podsumowanie" in r.text + + +def test_routes_exist(): + assert '@app.get("/compile"' in MAIN and '@app.post("/compile"' in MAIN + + +# ─────────────────────────── kolejność w raporcie ──────────────────────── + +def test_report_order_matches_the_brief(): + """Prośba partnerów: najpierw imię i nazwisko, potem wprowadzone dane, potem + RYSUNEK kosmogramu, a dopiero po nim interpretacja natalna i predykcje.""" + person = TPL.index('id="reportPerson"') + wheel = TPL.index("wheel_svg") + natal = TPL.index('id="reportNatal"') + preds = TPL.index('id="reportPredictions"') + assert person < wheel < natal < preds + + +def test_person_name_heads_the_report(): + assert 'id="reportPerson"' in TPL + assert "form.person" in TPL + + +# ──────────────────────── zbieranie materiału z magazynów ──────────────── + +def test_reads_stores_through_their_api_not_raw_storage(): + """Format danych ma jednego właściciela — moduł, który je zapisuje. + Sięganie tu wprost do localStorage rozjechałoby się przy pierwszej zmianie.""" + assert "window.astrololoNatal" in JS + assert "window.astrololoPredictions" in JS + # chodzi o brak BEZPOŚREDNIEGO dostępu, nie o samo słowo (pada w komentarzu) + assert "localStorage.getItem" not in JS + assert "localStorage.setItem" not in JS + + +def test_stores_are_loaded_on_the_tab(): + for src in ("natal.js", "predictions.js", "compile.js"): + assert src in TPL, f"brak {src} na zakładce" + + +def test_stores_load_before_the_assembler(): + """Szukamy TAGÓW skryptów, nie samych nazw — te padają też w komentarzach.""" + def tag(name): + return TPL.index('src="/static/' + name + '"') + + assert tag("natal.js") < tag("compile.js") + assert tag("predictions.js") < tag("compile.js") + + +# ─────────────────────────── mówi, czego brakuje ───────────────────────── + +def test_shows_what_is_missing(): + assert 'id="readiness"' in TPL + assert "rdy-missing" in JS + for hint in ("Interpretacje", "Kalendarz"): + assert hint in JS, f"brak podpowiedzi, gdzie uzupełnić: {hint}" + + +def test_all_three_parts_are_checked(): + for label in ("Policzony horoskop", "Interpretacja natalna", "Predykcje okresowe"): + assert label in JS + + +# ───────────────────────────── natalna zapamiętywana ───────────────────── + +def test_natal_is_captured_on_interpret_tab(): + assert "natal.js" in INTERPRET + assert 'id="natalNote"' in INTERPRET + + +def test_natal_store_keeps_a_single_entry(): + """Natalna jest JEDNA — dotyczy momentu urodzenia, nie okresu. Ponowne + wygenerowanie ma podmienić, nie dokładać.""" + assert "setItem" in NATAL_JS and "push" not in NATAL_JS + + +def test_natal_only_stores_natal_profile(): + assert "'natal'" in NATAL_JS + + +def test_full_storage_is_reported(): + assert "pełny" in NATAL_JS + + +# ─────────────────────────────── bezpieczeństwo ────────────────────────── + +def test_ai_text_is_escaped_before_injection(): + """Treść od modelu wstawiamy do DOM — bez ucieczki byłby to wektor wstrzyknięcia.""" + assert "function esc" in JS + assert "replace(/