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.