Przejdź do głównej zawartości

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.

FiltrDo czego służy
SzukajImię lub nazwisko — zarówno osoby wykonującej czynność, jak i uczestnika, którego dotyczy
Typ aktywnościRezerwacje kortu, zajęcia indywidualne, grupowe, wakacyjne, półkolonie, Weekend z tenisem. Zajęcia spoza tych kategorii — np. Klub Seniora — pokazują się pod grupowymi
AkcjaCo 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
WykonawcaCzy czynność wykonał klient, czy pracownik
UczestnikKonkretny zawodnik (wyszukiwarka podpowiada osoby z wybranego miasta)
PracownikKonkretna osoba z zespołu — wyszukiwarka po imieniu, nazwisku i adresie e-mail
Od daty / Do datyZakres dat czynności
Godzina zdarzeniaPora dnia, o której czynność została wykonana — np. 08:00 – 16:00 dla godzin pracy recepcji
Szybki zakresSkró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

PlikRola
app/(dashboard)/dashboard/history/page.tsxServer component: walidacja roli, odczyt searchParams, wywołanie getActivityLog
app/(dashboard)/dashboard/history/components/HistoryTypeFilters.tsxPanel filtrów (client) — stan trzymany w URL
app/(dashboard)/dashboard/history/components/ActivityLogFeed.tsxLista wpisów, paginacja, okna szczegółów
app/(dashboard)/dashboard/history/components/ActivityLogItem.tsxPojedynczy wpis, w tym prezentacja wpisu zgrupowanego
lib/actions/activity-log.tsZapytania SQL i złożenie strumienia zdarzeń
lib/actions/activity-log-actor-sql.tsWspólne wyrażenia SQL rozpoznające wykonawcę wpisu
lib/actions/activity-log-type-sql.tsKlauzule rodzaju zajęć dla filtra „Typ aktywności" i wyrażenie source_type
lib/utils/series-entry-merge.tsGrupowanie zdarzeń serii cyklicznych
lib/utils/activity-log-hour-filter.tsOkno godzinowe liczone w czasie polskim
app/(dashboard)/dashboard/history/components/EmployeeFilterCombobox.tsxWyszukiwarka pracownika w filtrach
lib/utils/consent-entry-merge.tsScalanie 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:

PoleZnaczenie
batch_sizeLiczba objętych zajęć
batch_series_start_time / batch_series_end_timeZakres terminów serii
batch_occurrencesLista terminów (game_id, start_time, end_time, cancelled), maks. 200
batch_amount_totalSuma 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:

  1. employee — kartoteka pracowników recepcji; jeśli adres tam jest, stamtąd biorą się dane.
  2. user — konto logowania. Gdy rola to ADMIN, BACKOFFICE lub INSTRUCTOR, wpis podpisywany jest polami givenName / familyName tego konta.
  3. player — zawodnik o najniższym id przypisany 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 jako Pracownik, 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.typeKategoriaFiltrsource_type wpisu
bookingRezerwacjebooking
individualIndywidualneindividual
groupdowolnaGrupowegroup
otherWakacyjneWakacyjnevacation
otherpozostałeGrupowegroup

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 kwot
  • lib/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 terminu
  • lib/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 z game_cancelled
  • lib/actions/activity-log-type-sql.test.ts — dopasowanie rodzajów zajęć do filtrów (Klub Seniora pod grupowymi, wakacyjne osobno) i wyliczanie source_type