--- phase: 09-finalizacja plan: 02 type: execute wave: 1 depends_on: ["09-01"] files_modified: - wp-content/plugins/yacht-booking-system/integrations/ical/class-ical-feed.php - wp-content/plugins/yacht-booking-system/integrations/ical/class-ical-import.php - wp-content/plugins/yacht-booking-system/admin/class-admin.php - wp-content/plugins/yacht-booking-system/admin/views/settings.php - wp-content/plugins/yacht-booking-system/includes/class-yacht.php - wp-content/plugins/yacht-booking-system/admin/views/yacht-edit.php - wp-content/plugins/yacht-booking-system/includes/class-installer.php autonomous: false delegation: off --- ## Goal Wprowadzić dwukierunkową synchronizację z **jednym wspólnym Google Calendar** (przez iCal feed) z rozróżnieniem rezerwacji per jacht po prefiksie w tytule eventu (`"{nazwa_jachtu} - {tekst}"`). Frontend kalendarz dla danego jachtu pokazuje tylko jego rezerwacje. ## Purpose Klient (właściciel wypożyczalni) chce zarządzać rezerwacjami wszystkich jachtów w jednym Google Calendar — jednym widokiem floty. Tworzy ręcznie eventy w GCal (np. blokady, rezerwacje telefoniczne) używając prefiksu z nazwą jachtu, a strona automatycznie przypisuje je do właściwego jachtu. Jednocześnie rezerwacje złożone przez frontend trafiają z powrotem do tego samego kalendarza w spójnym formacie. ## Output - Globalny iCal Export feed (jeden URL z tokenem) — zawiera rezerwacje wszystkich jachtów z prefiksem nazwy jachtu w SUMMARY - Globalny iCal Import URL — parser dopasowuje prefiks do jachtu, importuje tylko rozpoznane eventy - Settings page — pola dla globalnego import URL i wyświetlanie globalnego export URL - Pole `_yacht_gcal_alias` (opcjonalny alias dla skróconej nazwy w GCal) - Per-yacht feedy iCal pozostają sprawne (kompatybilność wsteczna) ## Project Context @.paul/PROJECT.md @.paul/ROADMAP.md @.paul/STATE.md @.paul/codebase/architecture.md ## Source Files @wp-content/plugins/yacht-booking-system/integrations/ical/class-ical-feed.php @wp-content/plugins/yacht-booking-system/integrations/ical/class-ical-import.php @wp-content/plugins/yacht-booking-system/admin/class-admin.php @wp-content/plugins/yacht-booking-system/includes/class-yacht.php @wp-content/plugins/yacht-booking-system/includes/class-settings.php - **Mechanizm** — iCal feed (publiczny URL Google Calendar) zamiast OAuth API. Powód: klient nie chce przechodzić przez OAuth, woli dodać URL i mieć synchronizację automatyczną. → Odpowiedź: dwukierunkowo przez iCal — import z GCal feedu + export własnego feedu (admin subskrybuje go w GCal). - **Identyfikacja jachtu** — po prefiksie w tytule eventu, separator `" - "`. Przykład: `"Maja - Kowalski 5 osób"`, `"Kubuś - blokada serwisowa"`. → Odpowiedź: parser wyciąga tekst przed pierwszym `" - "`, dopasowuje case-insensitive do `post_title` jachtu lub `_yacht_gcal_alias` (jeśli ustawiony — ma priorytet). - **Eventy bez rozpoznanego jachtu** — ignorowane. → Odpowiedź: jeśli prefiks nie pasuje do żadnego jachtu (lub brak separatora `" - "`), event pomijany w imporcie. Brak fallbacku na "blokuj wszystkie" — klient nie chce niespodzianek. - **Push do GCal** — przez ten sam globalny feed iCal (admin subskrybuje feed pluginu w GCal). → Odpowiedź: rezerwacje ze strony pojawiają się w GCal po następnej refresh subskrypcji (Google odświeża iCal subscription co kilka godzin — limit Google'a, nie pluginu). - **Frontend filtrowanie** — kalendarz per jacht pokazuje tylko eventy danego jachtu. → Odpowiedź: bez zmian w widgecie — działa już teraz przez `_booking_yacht_id`. Wystarczy że import poprawnie przypisze yacht_id. ## AC-1: Globalny iCal Export feed ```gherkin Given admin ma kilka rezerwacji w bazie dla różnych jachtów (np. "Maja", "Kubuś") When admin otwiera URL `/yacht-ical-global/{token}.ics` Then odpowiedź ma Content-Type `text/calendar` And feed zawiera VEVENT dla każdej niezanullowanej rezerwacji wszystkich jachtów And SUMMARY każdego eventu ma format `"{nazwa_jachtu} - {imię_klienta}"` (lub z prefiksem `[Oczekująca]` gdy status pending) And UID jest stabilny (`booking-{id}@{domain}`) — kolejne fetche zwracają ten sam UID ``` ## AC-2: Globalny iCal Import — dopasowanie po prefiksie ```gherkin Given globalny iCal Import URL jest ustawiony w Settings And kalendarz Google zawiera event "Maja - Test rezerwacja" (jacht "Maja" istnieje w bazie) And kalendarz Google zawiera event "Nieznany - cokolwiek" (brak takiego jachtu) And kalendarz Google zawiera event "Brak separatora" (bez " - ") When uruchamiany jest cron `yacht_booking_ical_import` (lub manualny przycisk) Then plugin tworzy/aktualizuje booking dla jachtu "Maja" z source `ical_import_global` And blokuje dostępność `wp_yacht_availability` tylko dla yacht_id jachtu "Maja" And event "Nieznany" jest pominięty (logowane jako info, brak booking) And event "Brak separatora" jest pominięty And kolejny przebieg (nawet bez zmian w GCal) nie tworzy duplikatów (idempotencja po UID) ``` ## AC-3: Globalny Import — czyszczenie usuniętych eventów ```gherkin Given event "Maja - Foo" został zaimportowany przy poprzednim cron'ie When admin usuwa go z Google Calendar And uruchamia się kolejny cron import Then powiązany booking (z source `ical_import_global` i tym UID) jest usuwany And `Availability::clear_booking_availability()` zwalnia daty ``` ## AC-4: Alias jachtu (opcjonalny) ```gherkin Given jacht ma `post_title="Marina Maja Sailing 35"` i `_yacht_gcal_alias="Maja"` And event w GCal ma SUMMARY "Maja - Klient X" When uruchamia się import Then plugin dopasowuje event do tego jachtu po aliasie And tworzy booking z `_booking_yacht_id` = ID tego jachtu ``` ## AC-5: Frontend filtrowanie (regresja) ```gherkin Given booking dla jachtu "Maja" zaimportowany przez globalny iCal When klient otwiera stronę z widgetem `[yacht_calendar yacht_id=ID_MAJA]` Then kalendarz pokazuje datę rezerwacji jako zablokowaną And widget jachtu "Kubuś" NIE pokazuje tej daty jako zablokowanej ``` ## AC-6: Settings UI ```gherkin Given admin otwiera Settings → zakładka Google Calendar / iCal When widzi sekcję "Globalna synchronizacja iCal" Then widzi pole "iCal Import URL" (input type=url) — globalny URL kalendarza Google And widzi pole tylko-do-odczytu "iCal Export URL" z linkiem `/yacht-ical-global/{token}.ics` i przyciskiem "Kopiuj" And widzi przycisk "Wygeneruj nowy token" (regeneruje token, unieważniając poprzedni URL) And widzi przycisk "Importuj teraz" (manual trigger run_global_import) And w yacht-edit jest nowe pole "Alias dla Google Calendar" (placeholder: nazwa jachtu) — opcjonalne ``` Task 1: Globalny iCal Export feed + alias jachtu wp-content/plugins/yacht-booking-system/integrations/ical/class-ical-feed.php, wp-content/plugins/yacht-booking-system/includes/class-yacht.php, wp-content/plugins/yacht-booking-system/includes/class-installer.php 1. W `class-ical-feed.php` dodaj nową ścieżkę feed: - rewrite rule `^yacht-ical-global/([a-zA-Z0-9]+)\.ics$` → `index.php?yacht_ical_global=1&yacht_ical_token=$matches[1]` - query var `yacht_ical_global` - handler `handle_global_feed_request()` weryfikuje token przez `hash_equals` względem opcji `yacht_booking_global_ical_token` - metoda `output_global_ics()`: pobiera WSZYSTKIE bookingi (nie filtrowane po yacht), iteruje, dla każdego ustala SUMMARY = `"{nazwa_jachtu} - {imię_klienta}"` (analogicznie do istniejącego `output_ics`, ale z prefiksem nazwy jachtu z `Yacht::get_post_title( yacht_id )` zamiast tylko klienta) - skip bookingi ze statusem `cancelled` oraz z source `ical_import_global` (nie wysyłaj z powrotem do Google tego co stamtąd przyszło — anti-loop) - UID format: `booking-{id}@{domain}` (taki sam jak per-yacht — zachować stabilność) - X-WR-CALNAME: `"Wszystkie jachty - " . get_bloginfo('name')` 2. Helper `get_global_feed_token()` — generuje/zwraca token z opcji, `regenerate_global_token()` — nadpisuje token. 3. Helper `get_global_feed_url()` — zwraca pełny URL. 4. W `class-yacht.php` dodaj akcesory: - `get_gcal_alias( $yacht_id )` → `get_post_meta( $yacht_id, '_yacht_gcal_alias', true )` - `update_gcal_alias( $yacht_id, $alias )` → sanitize_text_field + update_post_meta 5. W `class-installer.php` (jeśli wersja schematu się ewoluuje) dodaj domyślne opcje: - `add_option( 'yacht_booking_global_ical_import_url', '' )` - `add_option( 'yacht_booking_global_ical_token', '' )` (token generowany lazy przy pierwszym fetch URL) - flush_rewrite_rules przy aktywacji (rewrite rule). Avoid: nie modyfikuj istniejących per-yacht feedów — działają jako fallback i są używane przez OTA/iCal subscribers innych systemów. - `php -l class-ical-feed.php` (syntax OK) - W przeglądarce: po zapisie pluginu i flush rewrite, URL `/yacht-ical-global/{token}.ics` zwraca .ics z eventami wszystkich jachtów, każdy z prefiksem nazwy jachtu - `curl /yacht-ical-global/{token}.ics | grep "SUMMARY"` zawiera np. `SUMMARY:Maja - Kowalski` - URL z błędnym tokenem zwraca 403 AC-1, AC-4 (część dot. aliasu — akcesory) zaspokojone Task 2: Globalny iCal Import + parser prefiksu wp-content/plugins/yacht-booking-system/integrations/ical/class-ical-import.php 1. Dodaj stałą `const GLOBAL_IMPORT_SOURCE = 'ical_import_global';` (osobna od per-yacht `IMPORT_SOURCE = 'ical_import'`). 2. Nowa metoda statyczna `run_global_import()`: - Pobiera URL z `get_option( 'yacht_booking_global_ical_import_url' )` - Jeśli pusty — return false - `wp_remote_get` z timeout 30s - parse_ics (re-use istniejącej metody, jest prywatna — wystawić jako protected lub wynieść do helpera; preferowane: zmienić visibility na `protected` lub utworzyć publiczną wrapping) - Pre-fetch: lista jachtów + mapa `lowercase_name_or_alias → yacht_id`. Budowa: dla każdego jachtu klucz = `_yacht_gcal_alias` (jeśli != '') albo `post_title`, wszystko `mb_strtolower` + trim. - Pre-fetch: `existing_global_map` — bookingi z source `GLOBAL_IMPORT_SOURCE`, mapa `uid → booking_id` (analogicznie do `get_existing_import_map` ale bez filtrowania po yacht). - Iteracja po eventach: a. Skip jeśli brak uid/start/end lub event w przeszłości (`strtotime($event['end']) < time()`) b. Parse SUMMARY: `$pos = mb_strpos( $summary, ' - ' )`. Jeśli `$pos === false` → log "skip: no separator", continue. c. `$prefix = mb_strtolower( trim( mb_substr( $summary, 0, $pos ) ) )` d. Lookup w mapie. Jeśli brak → log "skip: yacht not found for prefix '{prefix}'", continue. e. Wywołaj `upsert_global_booking( $yacht_id, $event, $existing_id )` (nowa metoda — jak `upsert_booking` ale z `_booking_source = GLOBAL_IMPORT_SOURCE`, `_booking_customer_name = "Google Calendar (import)"`, post_title `"GCal: {summary}"`). f. Dodaj uid do `$seen_uids`. - Po pętli: stale cleanup — dla każdego `$uid => $booking_id` w `$existing_global_map` którego nie ma w `$seen_uids` → `Availability::clear_booking_availability($booking_id)` + `wp_delete_post($booking_id, true)`. - `update_option( 'yacht_booking_global_ical_last_import', current_time('mysql') )`. - return true. 3. Hook cron: w `register()` dodać `add_action( 'yacht_booking_ical_global_import', array( __CLASS__, 'run_global_import' ) )` i w `setup_cron()` zarejestruj hourly. 4. Hook do uruchomienia obu importów (per-yacht + global) z jednego cron tick'a — nie zmieniać istniejącego, dodać osobny `wp_schedule_event` dla `yacht_booking_ical_global_import`. Avoid: - Nie usuwać per-yacht importu (kompatybilność) - Nie używać sztywno `strpos` po bajtach — używać `mb_*` (polskie znaki w nazwach jachtów: "Kubuś", "Maja Słoneczna") - Nie matchować po fragmentach — tylko exact equality po lower-case (inaczej "Maja" pasuje do "Maja Słoneczna" jednocześnie) - `php -l class-ical-import.php` - Test ręczny: stworzyć w Google Calendar event "Maja - test", subskrybować feed Google iCal URL w settings, kliknąć "Importuj teraz", sprawdzić że w admin → Bookings pojawia się booking przypisany do jachtu "Maja" - Event "FooBar - test" (brak takiego jachtu) — nie tworzy bookingu, w error log linia "skip: yacht not found" - Event "BezSeparatora" — nie tworzy bookingu - Drugi cron tick — brak duplikatu - Usunięcie eventu w GCal + cron → booking zniknął, daty zwolnione w `wp_yacht_availability` AC-2, AC-3, AC-4 (matching po aliasie) zaspokojone Task 3: Settings UI + yacht-edit alias + admin actions wp-content/plugins/yacht-booking-system/admin/class-admin.php, wp-content/plugins/yacht-booking-system/admin/views/settings.php, wp-content/plugins/yacht-booking-system/admin/views/yacht-edit.php 1. `settings.php` — dodaj sekcję "Globalna synchronizacja iCal": - Pole `yacht_booking_global_ical_import_url` (input type=url, full width) - Read-only display: globalny export URL (`ICal_Feed::get_global_feed_url()`) — input readonly + przycisk "Kopiuj" (JS clipboard) - Przycisk "Wygeneruj nowy token" (form POST z nonce + action `regenerate_global_ical_token`) - Przycisk "Importuj teraz" (form POST z nonce + action `run_global_ical_import`) - Wyświetl `yacht_booking_global_ical_last_import` ("Ostatni import: ...") - Krótka instrukcja: "Skopiuj Export URL i dodaj jako 'Z URL-a' w Google Calendar (Inne kalendarze → Z URL). Eventy w Google nazywaj wg wzorca: 'Nazwa jachtu - opis'. Wklej iCal URL kalendarza Google poniżej." 2. `class-admin.php` — w `process_settings_save()` (lub equivalent) zapisz nowe pole. 3. `class-admin.php` — `process_booking_actions()` (lub nowy `process_ical_actions()`): - obsłuż `regenerate_global_ical_token` → `ICal_Feed::regenerate_global_token()` + `?notice=token_regenerated` redirect - obsłuż `run_global_ical_import` → `ICal_Import::run_global_import()` + `?notice=import_done` redirect 4. `yacht-edit.php` — dodaj wiersz tabeli "Alias dla Google Calendar": - input text `yacht_gcal_alias`, value = `Yacht::get_gcal_alias( $yacht_id )` - Description: "Opcjonalny krótki alias używany w tytule eventu Google Calendar (np. 'Maja' zamiast 'Marina Maja Sailing 35'). Jeśli puste — używana jest pełna nazwa jachtu." 5. `class-admin.php::process_yacht_save()` — zapisz `yacht_gcal_alias` przez `Yacht::update_gcal_alias()`. 6. Dodaj admin notices dla token_regenerated i import_done. Avoid: - Nie usuwaj istniejących pól per-yacht ("Google Calendar ID" i "iCal Import URL") — out of scope. Klient zdecyduje w kolejnym planie czy je sprzątać. - Nie dotykaj OAuth UI (sekcja Google Calendar OAuth zostaje bez zmian). - `php -l` na każdym zmienionym pliku - W panelu admin → Settings: nowa sekcja jest widoczna, zapis URL działa, "Wygeneruj token" zmienia URL, "Importuj teraz" wywołuje import (sprawdzalne w error log lub bookingach) - W yacht-edit: pole alias zapisuje się i odczytuje - Token w URL ma >=20 znaków, zmiana tokenu unieważnia stary URL (403) AC-6 zaspokojone, AC-4 (UI dla aliasu) zaspokojone Dwukierunkowa synchronizacja iCal z jednym wspólnym Google Calendar: - Globalny export feed (admin subskrybuje w GCal) - Globalny import URL (plugin pobiera kalendarz Google) - Identyfikacja jachtu po prefiksie "Nazwa - opis" w tytule eventu - Frontend per-jacht pokazuje tylko swoje eventy 1. Settings → skopiuj Export URL. 2. W Google Calendar → "Inne kalendarze" → "Z URL-a" → wklej Export URL → potwierdź że pojawiają się rezerwacje wszystkich jachtów z prefiksem nazwy. 3. W Google Calendar (głównym admina) utwórz event ręczny: tytuł "Maja - Test ręcznie", data za tydzień. 4. Skopiuj URL iCal swojego głównego kalendarza Google (Ustawienia kalendarza → Tajny adres w formacie iCal). 5. Wklej do Settings pluginu → "iCal Import URL". 6. Kliknij "Importuj teraz". 7. Sprawdź: WP Admin → Rezerwacje → pojawia się booking "GCal: Maja - Test ręcznie" przypisany do jachtu "Maja". 8. Otwórz frontend kalendarz jachtu Maja — data zablokowana. Otwórz kalendarz innego jachtu — data wolna. 9. Usuń event w Google → uruchom "Importuj teraz" ponownie → booking znika. 10. Stwórz w GCal "Nieznany - test" → import → brak bookingu (sprawdź WP error log: "skip: yacht not found"). Wpisz "approved" gdy wszystkie 10 punktów weryfikacji przejdzie, lub opisz problemy do poprawy ## DO NOT CHANGE - `class-availability.php` (logika availability stabilna) - `class-booking.php` (CPT booking — nie ruszamy) - `wp_yacht_availability` schema (zablokowane) - `frontend/class-calendar-widget.php` i `frontend/class-shortcode.php` (widget renderingu — działa już per-yacht) - `class-rest-controller.php` (REST endpointy — bez zmian) - OAuth integration (`class-oauth-handler.php`, `class-gcal-service.php`, `class-sync-controller.php`) — out of scope tego planu, push przez OAuth zostaje na razie jak jest (drugi mechanizm; klient użyje iCal a OAuth zignoruje, lub w kolejnym planie wyłączymy) - Per-yacht iCal feed (`output_ics`) i per-yacht import (`run_import` + `import_for_yacht`) — pozostają funkcjonalne dla kompatybilności wstecznej ## SCOPE LIMITS - Plan NIE usuwa pól per-yacht ("Google Calendar ID", "iCal Import URL") z yacht-edit — to decyzja na osobny plan po pozytywnej weryfikacji nowej globalnej synchronizacji - Plan NIE wyłącza OAuth push do GCal — drugi (równoległy) mechanizm; jeśli klient nie połączy OAuth, nie jest aktywny - Brak nowych zależności (PHP libs, npm, CDN) - Brak zmian w bazie poza dodaniem `wp_options` keys i opcjonalnym yacht meta `_yacht_gcal_alias` - Brak tłumaczeń .pot w tym planie (Phase 9 osobno) Przed deklaracją ukończenia planu: - [ ] `php -l` na wszystkich zmodyfikowanych plikach przechodzi - [ ] Globalny export URL zwraca poprawny .ics z prefiksami nazw jachtów - [ ] Globalny import URL parsuje eventy z GCal i przypisuje do jachtów po prefiksie - [ ] Eventy bez separatora lub bez dopasowanego jachtu są ignorowane (logowane) - [ ] Idempotencja: wielokrotne uruchomienie importu nie tworzy duplikatów - [ ] Stale cleanup: usunięcie eventu w GCal + import → booking znika - [ ] Frontend kalendarz per-jacht pokazuje tylko swoje rezerwacje (regresja) - [ ] Per-yacht feedy iCal nadal działają (nie zostały zepsute) - [ ] Settings UI: zapis URL, regeneracja tokenu, manual import, alias jachtu działają - [ ] Wszystkie acceptance criteria spełnione - [ ] Checkpoint human-verify zaakceptowany przez klienta - Klient może zarządzać rezerwacjami całej floty w jednym Google Calendar - Eventy ręczne w GCal z prefiksem "Nazwa jachtu - ..." automatycznie pojawiają się jako blokady na frontendzie tego jachtu - Rezerwacje ze strony pojawiają się w GCal admina (po refresh subskrypcji Google) - Każdy frontend kalendarz pokazuje tylko swoje rezerwacje - Zachowana kompatybilność wsteczna (per-yacht feedy + OAuth push nadal działają) Po ukończeniu utwórz `.paul/phases/09-finalizacja/09-02-SUMMARY.md` zawierający: - Co zbudowano (export feed, import, settings UI, alias) - Decyzje (separator " - ", lookup case-insensitive po post_title/alias, ignore unmatched) - Otwarte pytania na kolejny plan (czy usuwać pola per-yacht? czy wyłączać OAuth push?) - Ścieżkę testową odtworzeniową