diff --git a/gen_pages.py b/gen_pages.py
index 8892815..b216bd7 100644
--- a/gen_pages.py
+++ b/gen_pages.py
@@ -9,9 +9,11 @@ przez pole `resource` (patrz gen_viz.py).
Poza kartą na koncept skrypt generuje trzy strony zbiorcze:
pages/index.html spis wszystkich kart, pogrupowany katalogami
- pages/porownanie.html jedna tabela WSZYSTKICH ofert (nie regionów),
- sortowalna klikiem, złożona z samych frontmatterów;
- na górze podsumowanie per region z `wyjazd.md`
+ pages/porownanie.html jedna tabela WSZYSTKICH aktualnych ofert (nie
+ regionów), sortowalna klikiem, złożona z samych
+ frontmatterów; na górze podsumowanie per region
+ z `wyjazd.md`, na dole sekcja „Archiwum” z kartami
+ `priorytet: ARCHIWALNA` (riscercz na poprzedni termin)
pages/start.html landing: trzy kafle (porównanie / analiza / graf)
plus linki do kart czterech regionów
@@ -51,7 +53,12 @@ import yaml
BUNDLE_ROOT = Path(__file__).resolve().parent
PAGES_DIRNAME = "pages"
PAGES_DIR = BUNDLE_ROOT / PAGES_DIRNAME
-BUNDLE_NAME = "Ski 2027 — Skicircus Saalbach"
+BUNDLE_NAME = "Ski 2027 — wyjazd 6–13.02.2027"
+# Termin wyjazdu w jednym miejscu: leci do nagłówków stron i do raportu
+# braków. Zmiana terminu = zmiana tych dwóch stałych i regeneracja.
+TERM_LABEL = "6–13.02.2027"
+TERM_SHORT = "6–13.02" # nagłówek kolumny w tabeli porównawczej
+TERM_NOTE = "tydzień Fasching, szczyt sezonu"
RESERVED = {"index.md", "log.md"}
# Publiczny adres wystawki — jedyne miejsce, w którym siedzi domena. Używa go
@@ -420,11 +427,19 @@ table.sortable .name { font-weight: 600; }
tr.verified td { background: rgba(13, 148, 136, .14); }
tr.estimate td { background: rgba(100, 116, 139, .10); color: var(--muted); }
tr.estimate td.name a, tr.verified td.name a { color: var(--accent); }
+/* zweryfikowany BRAK miejsc bije kolor statusu ceny — reguła niżej wygrywa */
+tr.unavailable td { background: rgba(220, 38, 38, .16); }
+tr.unavailable td.name a { text-decoration: line-through; }
+td.avail { white-space: nowrap; font-weight: 600; }
+.archive { margin-top: 2.5rem; }
+.archive h2 { margin-bottom: .2rem; }
+.archive .lead { font-size: .88rem; margin-top: .2rem; }
.legend { font-size: .82rem; color: var(--muted); margin: .5rem 0 0; }
.legend .box { display: inline-block; width: .8em; height: .8em; border-radius: 3px;
border: 1px solid var(--line); vertical-align: -.05em; margin-right: .25em; }
.legend .box.v { background: rgba(13, 148, 136, .35); }
.legend .box.e { background: rgba(100, 116, 139, .25); }
+.legend .box.u { background: rgba(220, 38, 38, .35); }
/* miniatury w tabeli */
td.thumb { padding: .25rem; width: 128px; }
@@ -870,6 +885,7 @@ def parse_conclusions(text: str) -> list[str]:
# (nagłówek, pole frontmattera / klucz specjalny, czy sortować liczbowo)
COLUMNS: list[tuple[str, str, bool]] = [
("Oferta", "_nazwa", False),
+ (f"Dostępność {TERM_SHORT}", "_dostepnosc", False),
("Priorytet", "priorytet", False),
("Region", "_region", False),
("Miejscowość", "miejscowosc", False),
@@ -895,6 +911,56 @@ def is_offer(concept: Concept) -> bool:
return str(concept.frontmatter.get("type", "")).startswith("oferta")
+# --- dostępność terminu (pole `dostepnosc_6_13_02_2027`) ---------------
+
+AVAILABILITY_FIELD = "dostepnosc_6_13_02_2027"
+
+# (emoji z legendy, skrót do komórki, klucz sortowania). Kolejność kluczy
+# układa kolumnę decyzyjnie: potwierdzone wolne → do sprawdzenia → brak.
+AVAILABILITY_MARKS = (
+ ("✅", "✅ DOSTĘPNE", "1"),
+ ("🔍", "🔍 do sprawdzenia", "2"),
+ ("❌", "❌ BRAK (zweryf.)", "3"),
+)
+
+
+def availability(concept: Concept) -> str:
+ return str(concept.frontmatter.get(AVAILABILITY_FIELD) or "").strip()
+
+
+def availability_mark(value: str) -> tuple[str, str, str] | None:
+ for mark in AVAILABILITY_MARKS:
+ if value.startswith(mark[0]):
+ return mark
+ return None
+
+
+def _availability_cell(value: str) -> str:
+ """Komórka dostępności: emoji + jedno słowo, pełna treść w `title`.
+
+ Wartości z frontmattera są zdaniami („🔍 BRAK DANYCH — silnik online
+ pokaże od ręki”), a tabela ma się skanować wzrokiem — więc w komórce
+ zostaje sam werdykt, a uzasadnienie czeka pod kursorem.
+ """
+ if not value:
+ return '
— | '
+ mark = availability_mark(value)
+ if not mark:
+ return _cell(value, False)
+ _, short, key = mark
+ return (
+ f'{html.escape(short)} | '
+ )
+
+
+def is_archived(concept: Concept) -> bool:
+ """Karty z riserczu na poprzedni termin — poza tabelą i statusami."""
+ return str(concept.frontmatter.get("priorytet") or "").upper().startswith(
+ "ARCHIWALNA"
+ )
+
+
def _shorten(value: str) -> str:
"""Sam rdzeń wartości; doprecyzowanie w nawiasie/po myślniku idzie do title.
@@ -929,29 +995,22 @@ def _cell(value: str, numeric: bool, suffix: str = "", short: bool = True) -> st
return f"{html.escape(shown + suffix)} | "
-def render_comparison(concepts: list[Concept], regions: list[Region]) -> str:
- """Jedna tabela wszystkich ofert bundle'a, prosto z frontmatterów."""
- by_dir = {r.dirname: r for r in regions}
- offers = [c for c in concepts if is_offer(c)]
- # Domyślna kolejność: najtańsza rodzina 2+2 na górze — pierwsze pytanie,
- # które grupa zadaje. Resztę porządków daje sortowanie klikiem.
- offers.sort(
- key=lambda c: (
- sort_number(str(c.frontmatter.get("koszt_rodzina_2p2") or "")) or 1e9,
- c.title,
- )
- )
-
+def _offer_rows(offers: list[Concept], by_dir: dict[str, Region]) -> str:
+ """Wiersze tabeli porównawczej dla podanych ofert."""
rows = []
for concept in offers:
frontmatter = concept.frontmatter
status = str(frontmatter.get("status_ceny") or "")
upper = status.upper()
- css_row = (
+ classes = [
"verified" if "ZWERYFIKOWANE" in upper
else "estimate" if "SZACUNEK" in upper
else ""
- )
+ ]
+ # Zweryfikowany brak miejsc to najmocniejszy komunikat w wierszu —
+ # ważniejszy niż to, czy cena jest pewna.
+ if availability(concept).startswith("❌"):
+ classes.append("unavailable")
dirname = posixpath.dirname(posixpath.dirname(concept.id))
region = by_dir.get(dirname)
region_label = region.label if region else (dirname or "—")
@@ -984,6 +1043,8 @@ def render_comparison(concepts: list[Concept], regions: list[Region]) -> str:
cells.append(
f'{shown} | '
)
+ elif field == "_dostepnosc":
+ cells.append(_availability_cell(availability(concept)))
elif field == "www":
url = str(frontmatter.get("www") or "").strip()
if url:
@@ -1006,18 +1067,66 @@ def render_comparison(concepts: list[Concept], regions: list[Region]) -> str:
cells.append(
_cell(value, numeric, suffix, short=field not in NO_SHORTEN)
)
+ css_row = " ".join(c for c in classes if c)
klass = f' class="{css_row}"' if css_row else ""
rows.append(f"{''.join(cells)}
")
+ return "".join(rows)
+
+def _comparison_table(offers: list[Concept], by_dir: dict[str, Region]) -> str:
head = 'Zdjęcie | ' + "".join(
f'{html.escape(title)} | '
for title, _, _ in COLUMNS
)
- table = (
+ return (
''
- f"{head}
{''.join(rows)}
"
+ f"{head}{_offer_rows(offers, by_dir)}"
+ ""
)
+
+def _by_price(concept: Concept) -> tuple[float, str]:
+ # Domyślna kolejność: najtańsza rodzina 2+2 na górze — pierwsze pytanie,
+ # które grupa zadaje. Resztę porządków daje sortowanie klikiem.
+ return (
+ sort_number(str(concept.frontmatter.get("koszt_rodzina_2p2") or "")) or 1e9,
+ concept.title,
+ )
+
+
+def render_comparison(concepts: list[Concept], regions: list[Region]) -> str:
+ """Jedna tabela wszystkich aktualnych ofert bundle'a, prosto z frontmatterów.
+
+ Karty archiwalne (riscercz na poprzedni termin) idą do osobnej tabeli
+ na dole strony — nie mieszają się do porównania ani do statusów
+ dostępności, ale nie znikają z bazy.
+ """
+ by_dir = {r.dirname: r for r in regions}
+ offers = sorted((c for c in concepts if is_offer(c)), key=_by_price)
+ active = [c for c in offers if not is_archived(c)]
+ archived = [c for c in offers if is_archived(c)]
+
+ table = _comparison_table(active, by_dir)
+
+ counts: dict[str, int] = {}
+ for concept in active:
+ mark = availability_mark(availability(concept))
+ counts[mark[1] if mark else "—"] = counts.get(mark[1] if mark else "—", 0) + 1
+ tally = " · ".join(f"{label}: {n}" for label, n in sorted(counts.items()))
+
+ archive = ""
+ if archived:
+ archive = (
+ '\n'
+ "Archiwum — poza paczką riserczową 08.2026
\n"
+ f'Karty z riserczu na poprzedni termin '
+ f"(16–23.01.2027). Ceny i dostępność nie były weryfikowane na "
+ f"{html.escape(TERM_LABEL)} — nie wchodzą do porównania wyżej ani "
+ "do statusów dostępności. Zostają, bo tropy są nadal dobre.
\n"
+ f"{_comparison_table(archived, by_dir)}\n"
+ ""
+ )
+
summary = ""
if regions:
items = []
@@ -1042,14 +1151,22 @@ def render_comparison(concepts: list[Concept], regions: list[Region]) -> str:
content = (
"Porównanie ofert
\n"
- f'Wszystkie {len(offers)} rozważanych noclegów w jednej '
- "tabeli — dane prosto z frontmatterów kart. Kliknij nagłówek, żeby "
- "posortować; domyślnie od najtańszej rodziny 2+2.
\n"
+ f'Wszystkie {len(active)} rozważanych noclegów na termin '
+ f"{html.escape(TERM_LABEL)} ({html.escape(TERM_NOTE)}) "
+ "w jednej tabeli — dane prosto z frontmatterów kart. Kliknij nagłówek, "
+ "żeby posortować; domyślnie od najtańszej rodziny 2+2.
\n"
f"{summary}\n"
"Wszystkie oferty
\n"
f"{table}\n"
'cena zweryfikowana '
- ' szacunek do potwierdzenia
'
+ ' szacunek do potwierdzenia '
+ ' zweryfikowany brak miejsc
'
+ f"Dostępność {html.escape(TERM_SHORT)}: ✅ DOSTĘPNE (zweryfikowane) · "
+ "🔍 do sprawdzenia (obiekt nie publikuje kalendarza) · "
+ "❌ BRAK (zweryfikowane). "
+ f"Bilans: {html.escape(tally)}. Najedź na komórkę, żeby zobaczyć "
+ "pełną notatkę.\n"
+ f"{archive}"
)
return _page_shell(
"Porównanie ofert", 0, content, body_class="wide", script=SORT_JS
@@ -1062,8 +1179,8 @@ def render_comparison(concepts: list[Concept], regions: list[Region]) -> str:
# tylko ścieżki root-absolutne działają pod obiema (patrz docstring modułu).
TILES = [
("/pages/porownanie.html", "Porównanie ofert",
- "Wszystkie noclegi w jednej sortowalnej tabeli: ceny, status ceny, "
- "priorytet, odległość od wyciągu."),
+ "Wszystkie noclegi w jednej sortowalnej tabeli: dostępność na "
+ f"{TERM_SHORT}, ceny, status ceny, priorytet, odległość od wyciągu."),
("/pages/wyjazd.html", "Analiza wariantów",
"Cztery regiony obok siebie: skipassy, dojazd, budżet całkowity grupy "
"i wnioski przekrojowe."),
@@ -1111,7 +1228,8 @@ def render_start(concepts: list[Concept], regions: list[Region]) -> str:
content = (
f"{html.escape(BUNDLE_NAME)}
\n"
- '16–23.01.2027, 4 rodziny / 14 osób, wyjazd z Warszawy. '
+ f'
{html.escape(TERM_LABEL)} '
+ f"({html.escape(TERM_NOTE)}), 4 rodziny / 14 osób, wyjazd z Warszawy. "
"Od czego zacząć:
\n"
f'\n'
"Regiony
\n"
@@ -1383,7 +1501,7 @@ def missing_data(concepts: list[Concept]) -> list[tuple[str, list[str]]]:
"""Czego brakuje w ofertach — bez zgadywania, tylko z tego, co jest."""
report = []
for concept in concepts:
- if not str(concept.frontmatter.get("type", "")).startswith("oferta"):
+ if not is_offer(concept) or is_archived(concept):
continue
gaps = []
status = str(concept.frontmatter.get("status_ceny") or "")
@@ -1391,10 +1509,13 @@ def missing_data(concepts: list[Concept]) -> list[tuple[str, list[str]]]:
gaps.append("brak pola `status_ceny`")
elif "SZACUNEK" in status.upper():
gaps.append(f"cena niepotwierdzona ({status})")
- # Danymi o dostępności jest osobna sekcja (jak w glemm-lodge), a nie
- # samo słowo w tekście — "duża dostępność" w opisie się nie liczy.
- if not re.search(r"^#+\s*.*dostępnoś", concept.body, re.IGNORECASE | re.M):
- gaps.append("brak danych o dostępności terminu 16–23.01.2027")
+ # Dostępność czytamy z pola frontmattera, nie z nagłówka w tekście —
+ # werdykt ✅/❌/🔍 jest danymi, a nie tym, czy ktoś napisał sekcję.
+ value = availability(concept)
+ if not value:
+ gaps.append(f"brak pola `{AVAILABILITY_FIELD}`")
+ elif value.startswith("🔍"):
+ gaps.append(f"dostępność {TERM_LABEL} do weryfikacji ({_shorten(value)})")
open_todos = len(re.findall(r"^\s*[-*]\s+\[ \]", concept.body, re.M))
if open_todos:
gaps.append(f"otwartych pozycji „Do zrobienia”: {open_todos}")
@@ -1458,10 +1579,21 @@ def main() -> int:
f"Regiony z wyjazd.md: {len(regions)} "
f"({', '.join(r.label for r in regions) or '—'})"
)
+ active = [c for c in offers if not is_archived(c)]
print(
- f"Oferty w tabeli porównawczej: {len(offers)}, "
+ f"Oferty w tabeli porównawczej: {len(active)} "
+ f"(+{len(offers) - len(active)} archiwalnych w osobnej sekcji), "
f"ze zdjęciami: {len(with_photos)} (zdjęcia dokłada gen_images.py)"
)
+ marks = [availability_mark(availability(c)) for c in active]
+ print(
+ f"Dostępność {TERM_LABEL}: "
+ + " · ".join(
+ f"{label}: {sum(1 for m in marks if m and m[1] == label)}"
+ for _, label, _ in AVAILABILITY_MARKS
+ )
+ + f" · bez pola: {sum(1 for m in marks if m is None)}"
+ )
gaps = missing_data(concepts)
print()
diff --git a/gen_viz.py b/gen_viz.py
index 56e1a40..bdfc2d5 100644
--- a/gen_viz.py
+++ b/gen_viz.py
@@ -71,7 +71,7 @@ from urllib.parse import urlsplit, urlunsplit
import yaml
BUNDLE_ROOT = Path(__file__).resolve().parent
-BUNDLE_NAME = "Ski 2027 — Skicircus Saalbach"
+BUNDLE_NAME = "Ski 2027 — wyjazd 6–13.02.2027"
CATALOG = Path(os.environ.get("KNOWLEDGE_CATALOG", "/tmp/knowledge-catalog"))
sys.path.insert(0, str(CATALOG / "okf" / "src"))
diff --git a/pages/index.html b/pages/index.html
index 253d29e..0e1eec1 100644
--- a/pages/index.html
+++ b/pages/index.html
@@ -3,7 +3,7 @@
-Karty — Ski 2027 — Skicircus Saalbach
+Karty — Ski 2027 — wyjazd 6–13.02.2027