Historia aktywności
Chronologiczny rejestr wszystkiego, co dzieje się z rezerwacjami, zajęciami, półkoloniami i Weekendem z tenisem: kto co utworzył, zmienił, anulował, kto się zapisał, kto zapłacił i kto zaakceptował regulamin. Widok dostępny jest dla ról ADMIN i BACKOFFICE pod adresem /dashboard/history.
👤 Instrukcja dla pracownika
Po co to jest
Historia odpowiada na pytania typu:
- Kto zapisał tego zawodnika na kurs i kiedy?
- Kto przesunął zajęcia z wtorku na środę?
- Czy klient zapłacił sam przez internet, czy pracownik oznaczył płatność na recepcji?
- Kto usunął uczestnika bez zwrotu środków?
- Kiedy klient zaakceptował regulamin zapisu?
Grupowanie zapisów cyklicznych
Jedna czynność na serii cyklicznej dotyka wszystkich jej terminów. Zapisanie zawodnika na kurs trwający dwadzieścia tygodni to dwadzieścia osobnych zdarzeń w bazie — wcześniej dawało to dwadzieścia (a razem z utworzeniem zajęć czterdzieści) wierszy w historii.
Teraz taka czynność to jeden wpis:
- w nagłówku widnieje plakietka z liczbą objętych zajęć (np. „20 zajęć"),
- opis mówi wprost, ilu terminów dotyczy czynność i w jakim okresie („— 20 terminów w okresie 08/08/2026 – 19/12/2026"),
- pole Termin pokazuje zakres dat serii zamiast pojedynczej daty,
- przycisk Pokaż terminy (20) rozwija listę wszystkich objętych dat; kliknięcie daty otwiera szczegóły dokładnie tych zajęć,
- dla płatności zbiorczych pojawia się pole Łącznie z sumą kwot,
- dla wygasłego zapisu na zajęcia stałe zamiast Łącznie pojawia się Do zapłaty było z kwotą pierwszego miesiąca i liczbą terminów, które ta kwota obejmowała („412,50 zł za 5 terminów") — czyli z tym, o co klient faktycznie był proszony, a nie z wartością całego cyklu, który zwolniono. Nagłówek wpisu nadal podaje wszystkie anulowane terminy, więc obie liczby widać obok siebie (patrz Zapis na zajęcia stałe).
Wpisy scalają się tylko wtedy, gdy dotyczą tej samej serii, tego samego uczestnika, tej samej czynności i tego samego pracownika, a zdarzenia nastąpiły blisko siebie w czasie (do 10 minut przerwy między kolejnymi). Dzięki temu semestr płatności rozłożonych na kolejne miesiące nadal widnieje jako osobne wpisy — scala się tylko to, co faktycznie było jedną czynnością.
Wpisy spoza serii cyklicznych (pojedyncze zajęcia, rezerwacje kortu, półkolonie) nie są grupowane.
Filtry
Panel filtrów rozwija się przyciskiem Filtry (na desktopie jest domyślnie otwarty, na telefonie zwinięty). Licznik przy przycisku pokazuje, ile filtrów jest aktywnych.
| Filtr | Do czego służy |
|---|---|
| Szukaj | Imię lub nazwisko — zarówno osoby wykonującej czynność, jak i uczestnika, którego dotyczy |
| Typ aktywności | Rezerwacje kortu, zajęcia indywidualne, grupowe, wakacyjne, półkolonie, Weekend z tenisem. Zajęcia spoza tych kategorii — np. Klub Seniora — pokazują się pod grupowymi |
| Akcja | Co się wydarzyło: utworzenie, zmiana, anulowanie, zapis uczestnika, rezygnacja, wygaśnięcie płatności, opłacenie, zwrot środków, usunięcie bez zwrotu, przypisanie do grupy, akceptacja regulaminu |
| Wykonawca | Czy czynność wykonał klient, czy pracownik |
| Uczestnik | Konkretny zawodnik (wyszukiwarka podpowiada osoby z wybranego miasta) |
| Pracownik | Konkretna osoba z zespołu — wyszukiwarka po imieniu, nazwisku i adresie e-mail |
| Od daty / Do daty | Zakres dat czynności |
| Godzina zdarzenia | Pora dnia, o której czynność została wykonana — np. 08:00 – 16:00 dla godzin pracy recepcji |
| Szybki zakres | Skróty: Dziś, 7 dni, 30 dni, Ten miesiąc — ponowne kliknięcie aktywnego skrótu czyści daty |
Filtr Godzina zdarzenia dotyczy momentu wykonania czynności, a nie godziny zajęć — tak samo jak filtry dat. Godzina „do" jest wyłączna: zakres 08:00 – 16:00 obejmuje czynności do 15:59 włącznie. Godziny liczone są w czasie polskim, niezależnie od pory roku.
Filtr Pracownik działa na adresie e-mail osoby, która zapisała się w zdarzeniu. Rezerwacje założone przez klienta przez internet nie mają pracownika i nie pojawią się przy wybranym pracowniku — do ich wyłowienia służy filtr Wykonawca: Klient.
Miasto i lokalizację (ulicę) wybiera się globalnym przełącznikiem w nagłówku panelu — obowiązuje on także w historii, dlatego nie powtarza się go w filtrach.
Pod filtrami wyświetlają się plakietki aktywnych filtrów. Każdą można zdjąć krzyżykiem, a przycisk Wyczyść wszystkie kasuje je naraz. Zmiana dowolnego filtra wraca na pierwszą stronę wyników.
🛠️ Dokumentacja techniczna
Struktura
| Plik | Rola |
|---|---|
app/(dashboard)/dashboard/history/page.tsx | Server component: walidacja roli, odczyt searchParams, wywołanie getActivityLog |
app/(dashboard)/dashboard/history/components/HistoryTypeFilters.tsx | Panel filtrów (client) — stan trzymany w URL |
app/(dashboard)/dashboard/history/components/ActivityLogFeed.tsx | Lista wpisów, paginacja, okna szczegółów |
app/(dashboard)/dashboard/history/components/ActivityLogItem.tsx | Pojedynczy wpis, w tym prezentacja wpisu zgrupowanego |
lib/actions/activity-log.ts | Zapytania SQL i złożenie strumienia zdarzeń |
lib/actions/activity-log-actor-sql.ts | Wspólne wyrażenia SQL rozpoznające wykonawcę wpisu |
lib/actions/activity-log-type-sql.ts | Klauzule rodzaju zajęć dla filtra „Typ aktywności" i wyrażenie source_type |
lib/utils/series-entry-merge.ts | Grupowanie zdarzeń serii cyklicznych |
lib/utils/activity-log-hour-filter.ts | Okno godzinowe liczone w czasie polskim |
app/(dashboard)/dashboard/history/components/EmployeeFilterCombobox.tsx | Wyszukiwarka pracownika w filtrach |
lib/utils/consent-entry-merge.ts | Scalanie dokumentów zaakceptowanych jednym kliknięciem |
Skąd biorą się zdarzenia
Zdarzenia zajęć trzymane są w game.event_log (JSON) i zmaterializowane w tabeli activity_log (migracja 0164, utrzymywana triggerami na game). Rezerwacje, półkolonie i kursy weekendowe mają własne źródła (booking, camp_audit_log, tennis_course_audit_log, consent_log).
getActivityLog odpala każdą kategorię zdarzeń jako osobne podzapytanie (buildGame*SQL, buildCamp*SQL, …), obudowane przez wrapActivityLogSubquery wspólnymi filtrami (miasto, ulica, uczestnik, pracownik, akcja, wykonawca, zakres dat) i limitem fetchCap. Wyniki są łączone w pamięci, deduplikowane, grupowane i dopiero potem stronicowane.
Potok scalania
mergeSeriesActivityEntries istnieje obok scalania po batchId, ponieważ batchId nie występuje we wszystkich zdarzeniach: zdarzenia recurring_game_attendee_added z generateRecurringGames nigdy go nie zapisywały, a zajęcia utworzone przed wprowadzeniem tego pola również go nie mają. Grupowanie opiera się więc na danych, które są zawsze dostępne.
Klucz grupy (groupKey): source_type + action_type + recurring_series_id + customer_player_id + wykonawca (update_user_email, w razie braku imię i nazwisko) + wariant (update_changed_fields dla zmian, typ zgody dla akceptacji regulaminu, event_log_event_type w pozostałych przypadkach).
Podział na klastry: w obrębie grupy wpisy sortowane są malejąco po czasie i dzielone wszędzie tam, gdzie przerwa między kolejnymi przekracza SERIES_GAP_MS (10 minut). Każdy klaster daje jeden wpis.
Wynikowy wpis dziedziczy po najnowszym zdarzeniu klastra i otrzymuje dodatkowo:
| Pole | Znaczenie |
|---|---|
batch_size | Liczba objętych zajęć |
batch_series_start_time / batch_series_end_time | Zakres terminów serii |
batch_occurrences | Lista terminów (game_id, start_time, end_time, cancelled), maks. 200 |
batch_amount_total | Suma kwot płatności w klastrze (jeśli którekolwiek zdarzenie ma kwotę) |
batch_occurrences wypełniane jest także na ścieżce scalania po batchId, dzięki czemu wpis scalony jedną metodą może zostać poprawnie wchłonięty przez drugą.
Ponieważ grupowanie potrafi zredukować kilkadziesiąt wierszy do jednego, fetchCap każdego podzapytania jest podwojony względem rozmiaru strony, żeby strony nie robiły się rzadkie.
Kto jest wykonawcą wpisu
Zdarzenia zapisują wyłącznie adres e-mail osoby, która je wywołała, więc imię, nazwisko i rodzaj wykonawcy (Pracownik / Klient) trzeba dopiero rozwiązać. Robią to wspólne wyrażenia z lib/actions/activity-log-actor-sql.ts, używane przez wszystkie podzapytania:
employee— kartoteka pracowników recepcji; jeśli adres tam jest, stamtąd biorą się dane.user— konto logowania. Gdy rola toADMIN,BACKOFFICElubINSTRUCTOR, wpis podpisywany jest polamigivenName/familyNametego konta.player— zawodnik o najniższymidprzypisany do tego adresu; ścieżka dla klientów oraz dla kont pracowniczych bez uzupełnionego imienia i nazwiska.
Krok drugi jest istotny, bo employee obejmuje tylko część zespołu — instruktorzy i większość adminów mają wyłącznie konto w user. Wcześniej ich czynności spadały od razu do kroku trzeciego, przez co czynność wykonana w panelu bywała podpisana pierwszym z brzegu profilem zawodnika należącym do tego adresu (np. profilem testowym) i oznaczana jako Klient.
Ta sama kolejność wyznacza actor_type i channel: adres uznawany jest za pracowniczy, gdy istnieje w employee albo ma w user jedną z ról pracowniczych.
Wyjątkiem są zdarzenia, które klient może wywołać sam:
left— wypisanie się nigdy nie pokazuje wykonawcy, niezależnie od tego, czyj to adres.joined,substituted_to,substitution_completed— wykonawca ukrywany jest tylko wtedy, gdy adres nie jest pracowniczy. Adres pracowniczy jest od tej zmiany podpisywany i oznaczany jakoPracownik, więc instruktor zapisujący własne dziecko w aplikacji klienckiej zobaczy w historii wpis pracowniczy (channel = employee), a nie samodzielny zapis online.
Gałąź anulowanych zajęć czyta wykonawcę ze zdarzenia game_cancelled w game.event_log (najnowszego, gdy zajęcia były anulowane i przywracane wielokrotnie), a nie z event_log[0] — ten pierwszy wpis to utworzenie zajęć, więc anulowanie bywało podpisane osobą, która zajęcia założyła.
Filtry po stronie serwera
Parametry URL: city, street, historyType, action, actor, employee, search, playerId, playerName, dateFrom, dateTo, hourFrom, hourTo, page.
Który rodzaj zajęć trafia do którego filtra
Rodzaj zajęć trzyma kolumna activity_types.type: booking, individual, group oraz zbiorczy other. Wakacyjne to other z kategorią Wakacyjne; cała reszta other (dziś Klub Seniora) to zwykłe zajęcia grupowe — z tego wynika mapowanie:
activity_types.type | Kategoria | Filtr | source_type wpisu |
|---|---|---|---|
booking | — | Rezerwacje | booking |
individual | — | Indywidualne | individual |
group | dowolna | Grupowe | group |
other | Wakacyjne | Wakacyjne | vacation |
other | pozostałe | Grupowe | group |
Klauzule i wyrażenie source_type żyją w lib/actions/activity-log-type-sql.ts (buildActivityTypeClause, GAME_SOURCE_TYPE_SQL), wspólne dla wszystkich gałęzi buildGame*SQL. Wcześniej gałęzie te wymieniały wprost at.type IN ('individual', 'group') plus wakacje, przez co zajęcia typu other z inną kategorią nie pasowały do żadnej gałęzi i cała ich historia — łącznie z akceptacją regulaminu — była niewidoczna.
action i actor trafiają do wrapActivityLogSubquery jako AND action_type = ? / AND actor_type = ?. Dodatkowo pushSubquery w getActivityLog deklaruje, jakie action_type produkuje dane podzapytanie, i pomija te, które przy aktywnym filtrze i tak nie zwróciłyby wierszy — filtr po akcji zmniejsza więc liczbę zapytań do D1, a nie tylko odsiewa wyniki.
Daty (dateFrom, dateTo) podawane są jako yyyy-MM-dd w czasie Polski i przeliczane na granice UTC przez getDateFilterBounds; dateTo traktowane jest jako koniec dnia (granica wyłączna następnego dnia).
employee trafia do zapytania jako AND update_user_email = ?. Kolumna ta pochodziła wcześniej wyłącznie z pola user_email w event logu, przez co gałęzie rezerwacji i anulowanych zajęć miały w niej NULL — teraz niosą odpowiednio booking.created_by_employee_id / player.owner_email oraz adres ze zdarzenia game_cancelled, dzięki czemu filtr obejmuje również te wpisy i wskazuje osobę, która faktycznie anulowała zajęcia.
Godziny (hourFrom, hourTo) obsługuje lib/utils/activity-log-hour-filter.ts, już po odczycie z bazy: SQLite nie ma bazy stref czasowych, więc przeliczenie na czas polski (z uwzględnieniem czasu letniego) robi formatInTimeZone. Ponieważ filtr działa w pamięci, przy aktywnym oknie godzinowym fetchCap każdej gałęzi rośnie ośmiokrotnie (do twardego limitu BRANCH_FETCH_CAP), żeby strony nie robiły się puste.
Teksty
Wszystkie napisy pochodzą z przestrzeni activityLog w messages/pl.json i messages/en.json. Opisy wpisów zgrupowanych żyją w activityLog.entry.series.* — ActivityLogItem sięga po nie tylko wtedy, gdy batch_size > 1, więc wpis pojedynczy nigdy nie użyje mnogiego brzmienia. Akcje created i updated mają własne warianty serii (*CreatedSeries, *UpdatedSeries) sprzed tej zmiany i nadal z nich korzystają.
Testy
lib/utils/series-entry-merge.test.ts— grupowanie, granica czasowa, rozdzielanie uczestników, sumowanie kwotlib/utils/activity-log-hour-filter.test.ts— okno godzinowe w czasie letnim i zimowym, wyłączna godzina końcowa__tests__/app/history/activity-log-item-series.test.tsx— prezentacja wpisu zgrupowanego, rozwijanie terminów, otwieranie wybranego terminulib/actions/activity-log-actor-sql.test.ts— kolejność rozpoznawania wykonawcy (pracownik z kartoteki, konto pracownicze bez kartoteki, konto bez imienia, klient, adres nieznany) oraz odczyt osoby anulującej zajęcia zgame_cancelledlib/actions/activity-log-type-sql.test.ts— dopasowanie rodzajów zajęć do filtrów (Klub Seniora pod grupowymi, wakacyjne osobno) i wyliczaniesource_type