From 06ab1b24aa5adad95c32d53472834ad7abaf06b5 Mon Sep 17 00:00:00 2001 From: oskar Date: Tue, 4 Aug 2026 10:00:23 +0200 Subject: [PATCH] =?UTF-8?q?UI:=20kolumna=20dost=C4=99pno=C5=9Bci=206?= =?UTF-8?q?=E2=80=9313.02,=20sekcja=20Archiwum,=20nowy=20termin=20w=20nag?= =?UTF-8?q?=C5=82=C3=B3wkach?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit porownanie.html: - nowa kolumna „Dostępność 6–13.02” z pola `dostepnosc_6_13_02_2027`, skrócona do emoji + werdyktu (✅ DOSTĘPNE / 🔍 do sprawdzenia / ❌ BRAK (zweryf.)), pełna notatka w atrybucie title - sortowanie kolumny układa decyzyjnie (wolne → do sprawdzenia → brak), nie alfabetycznie po emoji - wiersz Glemm Lodge na czerwono i z przekreśloną nazwą: zweryfikowany brak miejsc bije kolor statusu ceny (reguła CSS niżej wygrywa) - karty `priorytet: ARCHIWALNA` wypadają z tabeli i z bilansu do osobnej sekcji „Archiwum” na dole strony - legenda i bilans statusów pod tabelą Termin wyjazdu wyjechał do stałych TERM_LABEL/TERM_SHORT/TERM_NOTE: nagłówki wszystkich stron (BUNDLE_NAME, też w gen_viz.py), lead start.html, opis kafla porównania i raport braków biorą go stąd. Zmiana terminu = zmiana stałych i regeneracja. Raport braków czyta teraz dostępność z pola frontmattera zamiast zgadywać z nagłówków w body, i pomija karty archiwalne. Wypis po regeneracji podaje bilans ✅/🔍/❌. Co-Authored-By: Claude Opus 5 (1M context) --- gen_pages.py | 200 +++++++++++++++--- gen_viz.py | 2 +- pages/index.html | 12 +- .../oferty/apartment-brandau-kappl.html | 10 +- pages/ischgl/oferty/apartments-kappl.html | 10 +- pages/ischgl/region.html | 10 +- .../oferty/haeuslerhof-valdaora.html | 10 +- .../oferty/residence-corones-valdaora.html | 10 +- pages/kronplatz/region.html | 10 +- pages/linki.html | 10 +- pages/porownanie.html | 21 +- .../oferty/alpenparks-rehrenberg.html | 10 +- .../oferty/alpenparks-sonnleiten.html | 10 +- pages/saalbach/oferty/dom-dla-grupy.html | 10 +- pages/saalbach/oferty/glemm-lodge.html | 10 +- .../oferty/kristall-schattbergxpress.html | 10 +- .../oferty/sportchalet-viehhofen.html | 10 +- pages/saalbach/region.html | 10 +- pages/sfl/oferty/apart-ideal-serfaus.html | 10 +- pages/sfl/oferty/haus-central-ladis.html | 10 +- pages/sfl/region.html | 10 +- pages/start.html | 16 +- pages/szablon-maila.html | 10 +- pages/wyjazd.html | 10 +- 24 files changed, 377 insertions(+), 64 deletions(-) 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'
    {tiles}
\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