diff --git a/docs/astrololo_wymagania.xlsx b/docs/astrololo_wymagania.xlsx index 7935e61..707ecd1 100644 Binary files a/docs/astrololo_wymagania.xlsx and b/docs/astrololo_wymagania.xlsx differ diff --git a/services/presentation/app/static/predictions.js b/services/presentation/app/static/predictions.js new file mode 100644 index 0000000..8f8e73a --- /dev/null +++ b/services/presentation/app/static/predictions.js @@ -0,0 +1,150 @@ +// Zapamiętane predykcje okresowe (PRE-22). +// +// Kalendarz pozwala policzyć horoskop na wybrany okres. Partnerzy chcą kilku +// okresów naraz — i żeby wszystkie trafiły potem do raportu (zakładka +// „Skompiluj", PRE-23/24). Tu jest magazyn i lista. +// +// Trzymamy w localStorage, tak jak wspólne dane formularza (PRE-21): serwer +// zostaje bezstanowy, żadne dane urodzeniowe ani treści z baz nie lądują po +// stronie usługi. Zakładka „Skompiluj" odczyta to samo miejsce. +(function () { + 'use strict'; + + var KEY = 'astrololo.predictions.v1'; + + function read() { + try { + var v = JSON.parse(localStorage.getItem(KEY) || '[]'); + return Array.isArray(v) ? v : []; + } catch (e) { + return []; // uszkodzony wpis nie może zablokować strony + } + } + + function write(list) { + try { + localStorage.setItem(KEY, JSON.stringify(list)); + return true; + } catch (e) { + return false; // najczęściej przepełniony magazyn + } + } + + function byPeriod(a, b) { + return String(a.from_date).localeCompare(String(b.from_date)); + } + + // Klucz tożsamości to OKRES. Ponowne policzenie tego samego zakresu ma + // podmienić wpis, a nie dokładać duplikat — inaczej lista puchłaby przy + // każdej próbie z innym modelem. + function upsert(entry) { + var list = read().filter(function (p) { + return !(p.from_date === entry.from_date && p.to_date === entry.to_date); + }); + list.push(entry); + list.sort(byPeriod); + return write(list) ? list : null; + } + + function remove(id) { + var list = read().filter(function (p) { return p.id !== id; }); + write(list); + return list; + } + + function val(sel) { + var el = document.querySelector(sel); + return el ? el.value.trim() : ''; + } + + /** Wyciąga świeżo policzony horoskop okresowy ze strony. */ + function capture() { + var text = val('#horoscopeText'); + var from = val('input[name=from_date]'); + var to = val('input[name=to_date]'); + if (!text || !from || !to) return null; + return { + id: from + '..' + to, + from_date: from, + to_date: to, + person: val('input[name=person]'), + text: text, + saved_at: new Date().toISOString() + }; + } + + function fmtDate(iso) { + return String(iso || '').slice(0, 10); + } + + function render() { + var host = document.getElementById('savedPredictions'); + if (!host) return; + var list = read(); + if (!list.length) { + host.innerHTML = '

Brak zapamiętanych predykcji. ' + + 'Policz horoskop na wybrany okres — zapamięta się sam i trafi do raportu.

'; + return; + } + var rows = list.map(function (p) { + var chars = (p.text || '').length; + return '' + + '' + fmtDate(p.from_date) + ' – ' + fmtDate(p.to_date) + '' + + '' + fmtDate(p.saved_at) + '' + + '' + chars.toLocaleString('pl-PL') + ' znaków' + + '' + + ''; + }).join(''); + host.innerHTML = + '
Zapamiętane predykcje (' + list.length + ') — wejdą do raportu
' + + '' + + '' + rows + '
OkresZapisanoObjętość
'; + + Array.prototype.forEach.call(host.querySelectorAll('[data-drop]'), function (btn) { + btn.addEventListener('click', function () { + remove(btn.getAttribute('data-drop')); + render(); + }); + }); + } + + function saveCurrent() { + var entry = capture(); + if (!entry) return; + var note = document.getElementById('predictionsNote'); + if (upsert(entry)) { + if (note) note.textContent = 'Zapamiętano predykcję ' + + entry.from_date + ' – ' + entry.to_date + '.'; + } else if (note) { + // Horoskopy bywają długie; gdy magazyn się przepełni, trzeba powiedzieć + // wprost, zamiast po cichu zgubić wynik. + note.textContent = 'Nie udało się zapamiętać — magazyn przeglądarki pełny. ' + + 'Usuń starsze predykcje i spróbuj ponownie.'; + } + render(); + } + + function ready(fn) { + if (document.readyState !== 'loading') fn(); + else document.addEventListener('DOMContentLoaded', fn); + } + + ready(function () { + if (!document.getElementById('savedPredictions')) return; // nie ta zakładka + render(); + + // 1) horoskop policzony przez okno postępu (strumień) + document.addEventListener('astrololo:horoscope', function (e) { + if (!e.detail || e.detail.profile === 'period') saveCurrent(); + }); + + // 2) wariant bez strumienia: strona wróciła z gotowym wynikiem po zwykłym + // POST. Zapis jest idempotentny (klucz = okres), więc odświeżenie strony + // niczego nie duplikuje. + if (document.querySelector('#horoscopeText')) saveCurrent(); + }); + + // Udostępniamy magazyn zakładce „Skompiluj" (PRE-23) — jedno źródło prawdy. + window.astrololoPredictions = { read: read, remove: remove, key: KEY }; +})(); diff --git a/services/presentation/app/static/progress.js b/services/presentation/app/static/progress.js index 197a55a..f692d06 100644 --- a/services/presentation/app/static/progress.js +++ b/services/presentation/app/static/progress.js @@ -113,6 +113,11 @@ document.addEventListener('DOMContentLoaded', function () { overlay.hidden = true; const host = document.getElementById('promptResult'); if (host) host.scrollIntoView({ behavior: 'smooth', block: 'start' }); + // Gotowy horoskop ogłaszamy zdarzeniem, zamiast zapisywać go tutaj: + // to okno postępu, a nie magazyn. Nasłuchuje predictions.js (PRE-22). + document.dispatchEvent(new CustomEvent('astrololo:horoscope', { + detail: { profile: profile } + })); return; } addLine(ev.message || ev.type, ev.type === 'error' ? 'err' diff --git a/services/presentation/app/templates/timeline.html b/services/presentation/app/templates/timeline.html index 630d92d..8c0fc59 100644 --- a/services/presentation/app/templates/timeline.html +++ b/services/presentation/app/templates/timeline.html @@ -85,8 +85,16 @@ {% endfor %} {% endif %} +{# Zapamiętane predykcje okresowe (PRE-22) — materiał dla zakładki „Skompiluj" #} +
+
+

+
+ +{# po progress.js — nasłuchuje zdarzenia, które tamten wysyła po gotowym horoskopie #} + {% endblock %} diff --git a/services/presentation/tests/test_predictions.py b/services/presentation/tests/test_predictions.py new file mode 100644 index 0000000..5f6c1c6 --- /dev/null +++ b/services/presentation/tests/test_predictions.py @@ -0,0 +1,91 @@ +"""Zapamiętane predykcje okresowe (PRE-22). + +Magazyn żyje w JS (localStorage), więc znów pilnujemy go strukturalnie. Sedno: +predykcje mają PRZEŻYĆ przejście na inną zakładkę i trafić do raportu, a lista +nie może puchnąć od powtórzonych prób na tym samym okresie. +""" +import pathlib + +APP = pathlib.Path(__file__).resolve().parents[1] / "app" +JS = (APP / "static" / "predictions.js").read_text(encoding="utf-8") +PROGRESS = (APP / "static" / "progress.js").read_text(encoding="utf-8") +TIMELINE = (APP / "templates" / "timeline.html").read_text(encoding="utf-8") + + +# ─────────────────────────────── podpięcie ─────────────────────────────── + +def test_script_loaded_on_calendar_tab(): + assert "predictions.js" in TIMELINE + + +def test_loaded_after_progress_script(): + """Nasłuchuje zdarzenia, które wysyła progress.js — musi być załadowany po nim, + inaczej przegapi pierwsze wywołanie.""" + assert TIMELINE.index("progress.js") < TIMELINE.index("predictions.js") + + +def test_calendar_has_container_for_the_list(): + assert 'id="savedPredictions"' in TIMELINE + + +def test_progress_announces_finished_horoscope(): + """Okno postępu ogłasza gotowy horoskop zdarzeniem, zamiast samo go zapisywać + — to okno postępu, a nie magazyn.""" + assert "astrololo:horoscope" in PROGRESS + assert "profile" in PROGRESS.split("astrololo:horoscope")[1][:200] + + +# ─────────────────────────────── zachowanie ────────────────────────────── + +def test_only_period_horoscopes_are_stored(): + """Interpretacja natalna ma NIE trafiać na listę predykcji okresowych.""" + assert "'period'" in JS + + +def test_same_period_replaces_instead_of_duplicating(): + """Ponowne policzenie tego samego zakresu podmienia wpis. Inaczej lista + puchłaby przy każdej próbie z innym modelem.""" + assert "function upsert" in JS + assert "p.from_date === entry.from_date" in JS + assert "p.to_date === entry.to_date" in JS + + +def test_entry_carries_period_and_text(): + for field in ("from_date", "to_date", "text", "saved_at"): + assert field in JS, f"wpis nie niesie pola {field}" + + +def test_incomplete_result_is_not_stored(): + """Bez tekstu albo bez dat nie ma czego zapamiętać — nie zapisujemy śmieci.""" + assert "if (!text || !from || !to) return null;" in JS + + +def test_list_can_be_pruned(): + assert "function remove" in JS + assert "data-drop" in JS + + +def test_full_storage_is_reported_not_swallowed(): + """Horoskopy bywają długie. Gdy magazyn się przepełni, użytkownik musi się + o tym dowiedzieć, zamiast po cichu stracić wynik.""" + assert "pełny" in JS or "pelny" in JS + assert "return false;" in JS + + +def test_corrupt_storage_does_not_break_the_page(): + assert "catch" in JS and "Array.isArray" in JS + + +def test_storage_is_exposed_for_the_compile_tab(): + """Zakładka „Skompiluj" (PRE-23) ma czytać to samo miejsce — jedno źródło prawdy.""" + assert "window.astrololoPredictions" in JS + + +def test_does_nothing_on_other_tabs(): + assert "if (!document.getElementById('savedPredictions')) return;" in JS + + +def test_reload_does_not_duplicate(): + """Wariant bez strumienia zapisuje przy wczytaniu strony — klucz to okres, + więc odświeżenie nie mnoży wpisów.""" + assert "id: from + '..' + to" in JS