diff --git a/gen_pages.py b/gen_pages.py
new file mode 100644
index 0000000..0224b38
--- /dev/null
+++ b/gen_pages.py
@@ -0,0 +1,549 @@
+#!/usr/bin/env python3
+"""Renderuje karty HTML konceptów bundle'a do katalogu pages/.
+
+Graf (viz.html) pokazuje strukturę; karty pokazują treść — ceny, dostępność,
+plusy/minusy, listy do zrobienia. Węzeł w grafie linkuje do karty przez pole
+`resource` (patrz gen_viz.py).
+
+Renderer markdown: używa `python-markdown`, jeśli jest zainstalowany
+(rozszerzenia tables + fenced_code + sane_lists); w przeciwnym razie własnego
+renderera na samej bibliotece standardowej — obsługuje nagłówki, akapity,
+tabele GFM, listy (w tym `- [ ]`), cytaty, bloki kodu, linie poziome oraz
+inline: **bold**, *kursywa*, `kod`, [linki](...). Który tor zadziałał, skrypt
+wypisuje przy uruchomieniu.
+
+Pliki .md NIE są modyfikowane — to czysty generator, tak samo jak gen_viz.py.
+Pliki zarezerwowane (index.md, log.md) nie są konceptami (§3.1) i nie
+dostają kart; linki do nich prowadzą do pages/index.html.
+
+Uruchomienie: python3 gen_pages.py
+"""
+
+from __future__ import annotations
+
+import html
+import posixpath
+import re
+import shutil
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+
+import yaml
+
+BUNDLE_ROOT = Path(__file__).resolve().parent
+PAGES_DIRNAME = "pages"
+PAGES_DIR = BUNDLE_ROOT / PAGES_DIRNAME
+BUNDLE_NAME = "Ski 2027 — Skicircus Saalbach"
+RESERVED = {"index.md", "log.md"}
+
+try: # renderer preferowany, jeśli dostępny w systemie
+ import markdown as _markdown_lib
+except ModuleNotFoundError:
+ _markdown_lib = None
+
+RENDERER = "python-markdown" if _markdown_lib else "fallback (biblioteka standardowa)"
+
+
+# --- model ------------------------------------------------------------
+
+
+@dataclass
+class Concept:
+ id: str # np. "oferty/glemm-lodge"
+ path: Path
+ frontmatter: dict
+ body: str
+
+ @property
+ def page(self) -> str:
+ """Ścieżka karty względem katalogu pages/."""
+ return f"{self.id}.html"
+
+ @property
+ def title(self) -> str:
+ return str(self.frontmatter.get("title") or self.id)
+
+
+def load_concepts() -> list[Concept]:
+ concepts: list[Concept] = []
+ for path in sorted(BUNDLE_ROOT.rglob("*.md")):
+ if ".git" in path.parts or path.name in RESERVED:
+ continue
+ text = path.read_text(encoding="utf-8")
+ frontmatter: dict = {}
+ body = text
+ if text.startswith("---\n"):
+ end = text.find("\n---", 3)
+ if end != -1:
+ frontmatter = yaml.safe_load(text[4:end]) or {}
+ body = text[end + 4 :]
+ concepts.append(
+ Concept(
+ id=path.relative_to(BUNDLE_ROOT).with_suffix("").as_posix(),
+ path=path,
+ frontmatter=frontmatter,
+ body=body.lstrip("\n"),
+ )
+ )
+ return concepts
+
+
+# --- linki ------------------------------------------------------------
+
+_LINK_TARGET_RE = re.compile(r"\]\(([^)\s]+\.md)((?:#[^)\s]*)?)\)")
+
+
+def rewrite_links(body: str, source: Concept, pages: dict[str, str]) -> str:
+ """Linki do plików .md → względne ścieżki do kart. External bez zmian."""
+ here = posixpath.dirname(source.page)
+
+ def repl(match: re.Match) -> str:
+ target, fragment = match.group(1), match.group(2)
+ if "://" in target:
+ return match.group(0)
+ if target.startswith("/"):
+ rel = target[1:]
+ else:
+ rel = posixpath.normpath(posixpath.join(posixpath.dirname(source.id), target))
+ concept_id = rel[:-3] if rel.endswith(".md") else rel
+ if concept_id in pages:
+ dest = pages[concept_id]
+ elif posixpath.basename(rel) in RESERVED:
+ dest = "index.html" # index.md / log.md nie mają kart
+ else:
+ return match.group(0)
+ return "](" + posixpath.relpath(dest, here or ".") + fragment + ")"
+
+ return _LINK_TARGET_RE.sub(repl, body)
+
+
+# --- renderer markdown: fallback na stdlib -----------------------------
+
+_CODE_RE = re.compile(r"`([^`]+)`")
+_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)\s]+)\)")
+_BOLD_RE = re.compile(r"\*\*(.+?)\*\*", re.S)
+_ITALIC_STAR_RE = re.compile(r"(? str:
+ codes: list[str] = []
+
+ def stash(match: re.Match) -> str:
+ codes.append(match.group(1))
+ return f"\x00c{len(codes) - 1}\x00"
+
+ text = _CODE_RE.sub(stash, text)
+ text = html.escape(text, quote=False)
+
+ def link(match: re.Match) -> str:
+ label, href = match.group(1), match.group(2)
+ attrs = ""
+ if "://" in href or href.startswith("mailto:"):
+ attrs = ' target="_blank" rel="noopener" class="ext"'
+ return f'{label}'
+
+ text = _LINK_RE.sub(link, text)
+ text = _BOLD_RE.sub(r"\1", text)
+ text = _ITALIC_STAR_RE.sub(r"\1", text)
+ text = _ITALIC_UNDER_RE.sub(r"\1", text)
+
+ for i, code in enumerate(codes):
+ text = text.replace(f"\x00c{i}\x00", f"{html.escape(code, quote=False)}")
+ return text
+
+
+def _alignments(sep: str) -> list[str]:
+ out = []
+ for cell in _cells(sep):
+ left, right = cell.startswith(":"), cell.endswith(":")
+ out.append("center" if left and right else "right" if right else "left")
+ return out
+
+
+def _cells(row: str) -> list[str]:
+ return [c.strip() for c in row.strip().strip("|").split("|")]
+
+
+def _starts_block(line: str) -> bool:
+ s = line.strip()
+ return (
+ not s
+ or bool(_HEADING_RE.match(s))
+ or bool(_HR_RE.match(s))
+ or s.startswith(("```", ">"))
+ or bool(_LIST_RE.match(line))
+ )
+
+
+def render_blocks(text: str) -> str:
+ lines = text.split("\n")
+ out: list[str] = []
+ i = 0
+ while i < len(lines):
+ line = lines[i]
+ stripped = line.strip()
+
+ if not stripped:
+ i += 1
+ continue
+
+ if stripped.startswith("```"):
+ i += 1
+ code: list[str] = []
+ while i < len(lines) and not lines[i].strip().startswith("```"):
+ code.append(lines[i])
+ i += 1
+ i += 1
+ out.append(f"
{html.escape(chr(10).join(code), quote=False)}")
+ continue
+
+ heading = _HEADING_RE.match(stripped)
+ if heading:
+ level = len(heading.group(1))
+ out.append(f"{render_blocks(chr(10).join(quoted))}") + continue + + item = _LIST_RE.match(line) + if item: + ordered = item.group(1)[0].isdigit() + items: list[str] = [] + while i < len(lines): + current = lines[i] + match = _LIST_RE.match(current) + if match: + items.append(match.group(2)) + i += 1 + elif current.startswith((" ", "\t")) and current.strip() and items: + items[-1] += " " + current.strip() + i += 1 + else: + break + rendered = [] + for raw in items: + task = _TASK_RE.match(raw) + if task: + checked = " checked" if task.group(1).lower() == "x" else "" + rendered.append( + f'
{render_inline(' '.join(paragraph))}
") + + return "\n".join(out) + + +def md_to_html(text: str) -> str: + if _markdown_lib: + return _markdown_lib.markdown( + text, extensions=["tables", "fenced_code", "sane_lists"] + ) + return render_blocks(text) + + +# --- szablon ---------------------------------------------------------- + +CSS = """ +:root { color-scheme: light dark; --fg:#0f172a; --muted:#64748b; --bg:#ffffff; + --line:#e2e8f0; --accent:#0d9488; --chip:#f1f5f9; --quote:#f8fafc; } +@media (prefers-color-scheme: dark) { + :root { --fg:#e2e8f0; --muted:#94a3b8; --bg:#0f172a; --line:#334155; + --accent:#2dd4bf; --chip:#1e293b; --quote:#1e293b; } +} +* { box-sizing: border-box; } +body { margin: 0 auto; padding: 1.25rem 1rem 4rem; max-width: 46rem; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + font-size: 17px; line-height: 1.55; color: var(--fg); background: var(--bg); + -webkit-text-size-adjust: 100%; } +nav { font-size: .85rem; margin-bottom: 1.25rem; } +nav a { color: var(--muted); } +h1 { font-size: 1.55rem; line-height: 1.25; margin: 0 0 .35rem; } +h2 { font-size: 1.2rem; margin: 2rem 0 .5rem; padding-top: .5rem; + border-top: 1px solid var(--line); } +h3 { font-size: 1.02rem; margin: 1.4rem 0 .4rem; } +p, ul, ol { margin: .6rem 0; } +ul, ol { padding-left: 1.3rem; } +li { margin: .25rem 0; } +li.task { list-style: none; margin-left: -1.3rem; } +li.task input { margin-right: .45rem; } +a { color: var(--accent); } +a.ext::after { content: " ↗"; font-size: .8em; color: var(--muted); } +code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .88em; + background: var(--chip); padding: .1em .35em; border-radius: 4px; } +pre { background: var(--chip); padding: .75rem; border-radius: 6px; overflow-x: auto; } +pre code { background: none; padding: 0; } +blockquote { margin: .9rem 0; padding: .6rem .9rem; background: var(--quote); + border-left: 3px solid var(--accent); border-radius: 0 6px 6px 0; } +blockquote p { margin: .2rem 0; } +hr { border: 0; border-top: 1px solid var(--line); margin: 1.5rem 0; } +.table-wrap { overflow-x: auto; margin: .8rem 0; -webkit-overflow-scrolling: touch; } +table { border-collapse: collapse; width: 100%; font-size: .92rem; } +th, td { border: 1px solid var(--line); padding: .4rem .55rem; vertical-align: top; } +th { background: var(--chip); font-weight: 600; } +.chip { display: inline-block; background: var(--chip); color: var(--muted); + font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; + padding: .15rem .5rem; border-radius: 999px; } +.lead { color: var(--muted); margin: .35rem 0 1.25rem; } +.meta { margin: 0 0 1.5rem; } +.meta summary { cursor: pointer; font-size: .85rem; color: var(--muted); + padding: .3rem 0; } +.meta table { font-size: .85rem; } +.meta th { width: 38%; text-align: left; font-weight: 500; word-break: break-word; } +.cards { list-style: none; padding: 0; margin: 0; } +.cards li { border: 1px solid var(--line); border-radius: 8px; padding: .8rem .9rem; + margin: .6rem 0; } +.cards a { font-weight: 600; text-decoration: none; } +.cards p { margin: .3rem 0 0; font-size: .9rem; color: var(--muted); } +footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--line); + font-size: .78rem; color: var(--muted); } +""" + + +def _page_shell(title: str, depth: int, content: str) -> str: + up = "../" * depth + return f""" + + + + +{html.escape(description)}
' if description else "" + + content = ( + f'{chip}\n' + f"{html.escape(description)}
Karty konceptów bundle\'a — pełna treść każdego dokumentu ' + "(ceny, dostępność, plusy/minusy). Struktura powiązań: " + 'graf.
\n' + "\n".join(sections) + ) + return _page_shell("Karty", 0, content) + + +# --- raport braków ---------------------------------------------------- + + +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"): + continue + gaps = [] + status = str(concept.frontmatter.get("status_ceny") or "") + if not status: + 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") + open_todos = len(re.findall(r"^\s*[-*]\s+\[ \]", concept.body, re.M)) + if open_todos: + gaps.append(f"otwartych pozycji „Do zrobienia”: {open_todos}") + if gaps: + report.append((concept.id, gaps)) + return report + + +# --- main ------------------------------------------------------------- + + +def main() -> int: + concepts = load_concepts() + pages = {c.id: c.page for c in concepts} + + if PAGES_DIR.exists(): + shutil.rmtree(PAGES_DIR) + PAGES_DIR.mkdir(parents=True) + + written = [] + for concept in concepts: + out = PAGES_DIR / concept.page + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(render_page(concept, pages), encoding="utf-8") + written.append(out) + + index = PAGES_DIR / "index.html" + index.write_text(render_index(concepts), encoding="utf-8") + written.append(index) + + print(f"Renderer markdown: {RENDERER}") + print(f"Zapisano {len(written)} plików w {PAGES_DIR}:") + for path in written: + size = path.stat().st_size / 1024 + print(f" {path.relative_to(BUNDLE_ROOT)} ({size:.1f} KiB)") + + gaps = missing_data(concepts) + print() + if gaps: + print(f"Oferty z brakami w danych ({len(gaps)}) — do uzupełnienia ręcznie:") + for concept_id, items in gaps: + print(f" ! {concept_id}") + for item in items: + print(f" – {item}") + else: + print("Braków w danych ofert nie wykryto.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pages/index.html b/pages/index.html new file mode 100644 index 0000000..091255e --- /dev/null +++ b/pages/index.html @@ -0,0 +1,73 @@ + + + + + +Karty konceptów bundle'a — pełna treść każdego dokumentu (ceny, dostępność, plusy/minusy). Struktura powiązań: graf.
+Wszystko poza noclegiem: teren narciarski, skipassy ALPIN CARD, dojazd z Warszawy, analiza terminów, koszty życia i budżet całkowity grupy.
Gotowy angielski mail z zapytaniem o 4 apartamenty dla 14 osób w terminie 16–23.01.2027 wraz z notatkami negocjacyjnymi.
Wyjazd narciarski 4 rodzin (14 osób) z Warszawy do Skicircus Saalbach w styczniu 2027, z celem budżetowym ~2 000 € za nocleg na rodzinę.
Najtańszy nowy obiekt na liście — przestronne apartamenty z prywatnymi saunami w Viehhofen, wariant rezerwowy o mieszanych opiniach, PLAN C.
Duży aparthotel ~2 min pieszo od stoku w Saalbach, z basenem i sauną w cenie — największa szansa na 4 wolne apartamenty w jednym tygodniu.
Koncept wynajęcia jednego domu wakacyjnego dla całej grupy zamiast czterech osobnych apartamentów — klimat chaletowy w połowie ceny Glemm Lodge.
Jedyna oferta z w pełni zweryfikowanym cennikiem (~4 370 €/rodzinę) — odrzucona budżetowo, służy jako punkt odniesienia cenowego.
Apartamenty 50 m od gondoli Schattberg X-press w centrum Saalbach — najlepsza lokalizacja w rozważanym budżecie, PLAN A.
Kameralny obiekt holenderskich gospodarzy w Viehhofen z oceną 5.0 — najlepszy stosunek ceny do jakości, PLAN B.
Najtańszy nowy obiekt na liście — przestronne apartamenty z prywatnymi saunami w Viehhofen, wariant rezerwowy o mieszanych opiniach, PLAN C.
+ + +Najtańszy "nowy" obiekt na liście: przestronne, nowoczesne apartamenty z prywatnymi saunami w Viehhofen. Traktowany jako rezerwa — cena kusi, ale opinie są mieszane i logistyka na stok słabsza niż u konkurencji.
+| Rodzina | Szacunkowy koszt/tydzień |
|---|---|
| 2× rodzina 2+2 | ~1 100–1 500 € |
| 2× rodzina 2+1 | ~950–1 250 € |
+ najniższa cena za nowy standard; prywatne sauny; duża dostępność (spory obiekt); pełna rezerwacja online.
+− powtarzające się w recenzjach zastrzeżenia do skibusa (mały, zatłoczony rano) i obsługi/organizacji — przed rezerwacją przeczytać świeże opinie z zimy 25/26; Viehhofen bez wieczornego życia; sumarycznie gorszy pakiet niż Sportchalet za podobne pieniądze.
+Duży aparthotel ~2 min pieszo od stoku w Saalbach, z basenem i sauną w cenie — największa szansa na 4 wolne apartamenty w jednym tygodniu.
+ + +Duży, nowoczesny aparthotel ~2 minuty pieszo od stoku w Saalbach. Kluczowa przewaga: skala obiektu — kilkadziesiąt apartamentów oznacza największe prawdopodobieństwo, że 4 jednostki będą wolne w jednym tygodniu, i pełną rezerwację online bez korespondencji.
+| Rodzina | Apartament | Szacunkowy koszt/tydzień |
|---|---|---|
| 2× rodzina 2+2 | 4-os. | ~1 400–1 900 € |
| 2× rodzina 2+1 | 3–4-os. | ~1 200–1 500 € |
+ basen dla dzieci, restauracja, wysoka dostępność, przewidywalna jakość sieciówki, rezerwacja i anulacja na warunkach online.
+− mniej "kameralnie" niż u prywatnego gospodarza; ceny sieciówki sztywniejsze w negocjacji (choć mail grupowy i tak warto wysłać); lokalizacja dobra, ale nie tak spektakularna jak Kristall przy gondoli.
+Koncept wynajęcia jednego domu wakacyjnego dla całej grupy zamiast czterech osobnych apartamentów — klimat chaletowy w połowie ceny Glemm Lodge.
+ + +Alternatywa dla "4 osobnych apartamentów": jeden duży dom wakacyjny dla całej czternastki. Klimat chaletowy jak w Glemm Lodge (wspólne kolacje, wieczory, dzieciaki razem), ale w budżecie ~połowy jego ceny — pod warunkiem odpuszczenia adresu w samym Saalbach.
+| Rodzina | Udział | Przy 5 500 €/tydzień |
|---|---|---|
| 2+2 | 4/14 | ~1 570 € |
| 2+1 | 3/14 | ~1 180 € |
+ najlepszy klimat grupowy; wspólne gotowanie tnie koszty jedzenia; zwykle najlepszy metraż za euro; jedna rezerwacja.
+− logistyka poranna (14 os. na skibus/auta); jedna kuchnia i salon = potrzebna zgodność grupy; pojedyncza rezerwacja = pojedyncze ryzyko (anulacja jednej rodziny obciąża resztę — spisać zasady z góry!); konkretny dom trzeba dopiero znaleźć.
+Jedyna oferta z w pełni zweryfikowanym cennikiem (~4 370 €/rodzinę) — odrzucona budżetowo, służy jako punkt odniesienia cenowego.
+ + +Jedyny obiekt z pełnymi zweryfikowanymi danymi (publiczny cennik i kalendarz obłożenia) — służy jako punkt odniesienia cenowego. Wraca do gry, jeśli właściciel zejdzie do ~5 000 €/apartament przy 7 osobach (precedens: cennik letni skaluje ceny wg liczby osób).
+| Okres | Sezon | Za noc | Za tydzień |
|---|---|---|---|
| 27.11–11.12.2026 | przedsezon | 800 € | 5 600 € |
| 12–18.12.2026 | niski | 900 € | 6 300 € |
| 19–25.12.2026 | wysoki | 1 000 € | 7 000 € |
| 26.12.2026–01.01.2027 | top | 1 200 € | 8 400 € |
| 02.01–05.03.2027 | wysoki | 1 000 € | 7 000 € |
| 06–19.03.2027 | niski | 900 € | 6 300 € |
| 20.03–04.04.2027 | późny | 800 € | 5 600 € |
Dodatkowo: sprzątanie końcowe 500 €/apartament, taksa 3 €/os./noc, kaucja zwrotna 900 €/apartament.
+| Termin | Top 1 | Top 2 | Top 3 | 2 apartamenty naraz? |
|---|---|---|---|---|
| 2–9.01.2027 | zajęty | WOLNY | zajęty | ✗ |
| 9–16.01.2027 | zajęty | zajęty | WOLNY | ✗ |
| 16–23.01.2027 | WOLNY | WOLNY | WOLNY | ✅ |
| Pozycja | Na apartament (7 os.) |
|---|---|
| Czynsz 7 nocy | 7 000 € |
| Sprzątanie | 500 € |
| Taksa (3 € × 7 os. × 7 nocy) | 147 € |
| Razem (bez kaucji 900 €) | 7 647 € |
Podział od osoby: rodzina 2+2 → ~4 370 €, rodzina 2+1 → ~3 277 €. Wniosek: 2× powyżej celu budżetowego (~2 000 €/rodzinę).
+Apartamenty 50 m od gondoli Schattberg X-press w centrum Saalbach — najlepsza lokalizacja w rozważanym budżecie, PLAN A.
+ + +Najlepsza lokalizacja w rozważanym budżecie: 50 m od gondoli Schattberg X-press — głównej stacji startowej całego Skicircusu, w ścisłym centrum Saalbach. Nastolatki wychodzą z narciarni prosto na gondolę, zero skibusa, zero odwożenia. Rodzice mają restauracje, bary i sklep w promieniu 50 m.
+| Rodzina | Apartament | Szacunkowy koszt/tydzień |
|---|---|---|
| Rodzina A (2+2) | 4-os. z 2 łazienkami | ~1 500–2 000 € |
| Rodzina B (2+2) | 4-os. z 2 łazienkami | ~1 500–2 000 € |
| Rodzina C (2+1) | 3–4-os. (mniejszy) | ~1 200–1 600 € |
| Rodzina D (2+1) | 3–4-os. (mniejszy) | ~1 200–1 600 € |
+ lokalizacja bezkonkurencyjna (za taki adres w Saalbach zwykle się dopłaca), każda rodzina ma prywatność i własną łazienkę, spa w budynku, parking w cenie, centrum = wieczorne życie dla nastolatków pod nosem.
+− brak wspólnego dużego salonu na wieczory całej ekipy (wieczory "na apartamentach" albo w knajpie); dostępność 4 jednostek naraz do potwierdzenia; centrum Saalbach bywa głośne w weekendy.
+Kameralny obiekt holenderskich gospodarzy w Viehhofen z oceną 5.0 — najlepszy stosunek ceny do jakości, PLAN B.
+ + +Kameralny obiekt w Viehhofen — wsi 3 km przed Saalbach — prowadzony przez holenderskich gospodarzy z oceną 5.0. Opinie gości wprost opisują scenariusz "kilka rodzin bookuje kilka apartamentów razem" — czyli dokładnie nasz przypadek. Najniższe ceny na liście przy bardzo dobrej jakości.
+Zależnie od konfiguracji apartamentów (do potwierdzenia mailem): wariant 4×(3–4 os.) albo 2×(6–7 os.) + 1×(3–4 os.). Przy zajęciu całego budynku wspólne wieczory de facto "jak w chalecie".
+| Rodzina | Szacunkowy koszt/tydzień |
|---|---|
| 2+2 | ~1 200–1 600 € |
| 2+1 | ~1 000–1 300 € |
+ najniższa cena przy oceny 5.0; klimat "cały dom dla nas"; elastyczni prywatni gospodarze = realna negocjacja grupowa; parking bez problemu; dwie opcje startu na stok (Zell am See gondolą / Skicircus skibusem) — fajna różnorodność w trakcie tygodnia.
+− to nie ski-in/ski-out: do Skicircusu 5–10 min skibusem (z 5 dzieci i sprzętem — logistyka poranna do ogarnięcia); Viehhofen jest cichy — zero wieczornego życia dla nastolatków (trzeba dojechać do Saalbach); brak silnika online — wszystko mailowo.
+Wszystko poza noclegiem: teren narciarski, skipassy ALPIN CARD, dojazd z Warszawy, analiza terminów, koszty życia i budżet całkowity grupy.
+ + +Baza wiedzy wyjazdu narciarskiego zimą 2026/27. Ten dokument zbiera wszystko, co nie dotyczy konkretnego noclegu: teren narciarski, skipassy, dojazd, terminy, koszty życia i logistykę grupy. Oferty noclegowe → katalog oferty/.
Karnet Ski ALPIN CARD obejmuje: Skicircus Saalbach-Hinterglemm-Leogang- Fieberbrunn + Schmittenhöhe (Zell am See) + lodowiec Kitzsteinhorn (Kaprun). Istotne: gondola zellamseeXpress w Viehhofen też jest na tym karnecie.
+| Taryfa 6 dni | Cena 2025/26 (orient.) | Prognoza 2026/27 (+3–5%) |
|---|---|---|
| Dorosły | ~430–450 € | ~445–470 € |
| Młodzież (do 18 lat) | ~ -25% od ceny dorosłego | ~335–355 € |
| Dziecko (do ~15 lat) | ~ -50% | ~225–235 € |
Szacunkowy koszt skipassów dla grupy 14 os. (8 dorosłych + 4 nastolatków
+| Rodzina | Skład | Skipassy 6 dni |
|---|---|---|
| 2+2 (nastolatki) | 2 dorosłych + 2 młodzież | ~1 570–1 650 € |
| 2+1 (dziecko) | 2 dorosłych + 1 dziecko | ~1 120–1 175 € |
+⚠️ Ceny 26/27 do potwierdzenia jesienią 2026 na alpincard.at. Dzieci/młodzież: decyduje rocznik, nie wiek w dniu wyjazdu — sprawdzić dokładne progi rocznikowe przy zakupie.
| Tydzień | Ocena | Uzasadnienie |
|---|---|---|
| 2–9.01 | ⚠️ średnio | ogon szczytu noworocznego: tłumy do ~6.01, najwyższe ceny noclegów |
| 9–16.01 | ✅ dobry | pierwszy "pusty" tydzień zimy, niższe ceny, świetny śnieg |
| 16–23.01 | ✅✅ rekomendowany | najluźniejsze stoki, pełna dostępność noclegów (m.in. jedyne okno na 2× Glemm Lodge), te same ceny co 9–16.01 |
| Pozycja | 14 osób łącznie |
|---|---|
| Noclegi (cel: ~1 200–2 000 €/apartament) | 5 500–7 500 € |
| Skipassy | 4 900–5 400 € |
| Dojazd (4 auta) | 1 200–1 400 € |
| Jedzenie + taksy | 2 500–3 500 € |
| RAZEM | ~14 000–18 000 € (≈ 1 000–1 300 €/os.) |
Gotowy angielski mail z zapytaniem o 4 apartamenty dla 14 osób w terminie 16–23.01.2027 wraz z notatkami negocjacyjnymi.
+ + +Wysłać ten sam mail do 2–3 obiektów równolegle, podmieniając [pola]. Wersja uniwersalna pod 4 apartamenty; przy Sportchalet/Glemm Lodge dopisać pytanie o zajęcie całego obiektu.
+Subject: Group inquiry — 4 apartments, week 16–23 Jan 2027 (14 persons)
+Dear [Sir or Madam / imię gospodarza],
+We are a group of four families from Poland (14 persons in total: 8 adults, 4 teenagers and 2 children) planning a ski week in Saalbach in January 2027.
+We would like to ask about availability and a group offer for:
+Could you please send us:
+Since we are booking four apartments for a full week directly with you, we would appreciate a group rate if possible.
+Best regards, [Imię i nazwisko] [Telefon / e-mail]
+Wyjazd narciarski 4 rodzin (14 osób) z Warszawy do Skicircus Saalbach w styczniu 2027, z celem budżetowym ~2 000 € za nocleg na rodzinę.
+ + +Wyjazd narciarski 4 rodzin: 2×(2+2 nastolatki) + 2×(2+1 dziecko) = 14 osób, wyjazd samochodami z Warszawy.
+Kontekst regionu, skipassów, dojazdu i budżetu całkowitego → region. Karty noclegów → oferty.
+| # | Oferta | Miejscowość | €/rodzinę 2+2 | Status | Priorytet |
|---|---|---|---|---|---|
| 1 | Kristall @ SchattbergXpress | Saalbach centrum, 50 m od gondoli | ~1 500–2 000 | szacunek | PLAN A |
| 2 | AlpenParks Sonnleiten | Saalbach, 2 min od stoku | ~1 400–1 900 | szacunek | PLAN A-bis |
| 3 | Sportchalet Viehhofen | Viehhofen, skibus | ~1 200–1 600 | szacunek | PLAN B |
| 4 | Dom 12–14 os. | Viehhofen/Maishofen/Fieberbrunn | ~1 400–1 900 | koncept | równoległy |
| 5 | AlpenParks Rehrenberg | Viehhofen | ~1 100–1 500 | szacunek | PLAN C |
| 6 | Glemm Lodge | Saalbach/Hinterglemm | ~4 370 | zweryfikowane | odrzucona (referencja) |
Każda karta oferty ma frontmatter YAML z polami OKF (type, title, description, timestamp, tags) oraz z polami rozszerzeń: id, nazwa, miejscowosc, gps, odleglosc_od_wyciagu, ocena, cena_za_apartament_tydzien, koszt_rodzina_2p2, koszt_rodzina_2p1, status_ceny (SZACUNEK / ZWERYFIKOWANE), priorytet, www, ostatnia_aktualizacja — stronę można generować statycznie prosto z tych pól (Astro/Hugo/Eleventy czytają frontmatter natywnie).