Przejdź do treści
School IT / Przestrzeń do nauki

Projekt — część 2: zapisy, filtry, nowe wydarzenia

Aplikacja z części 1 tylko pokazuje dane. Teraz zacznie je zmieniać: zapisy i wypisywanie, filtry na liście, formularz nowego wydarzenia i pamięć w przeglądarce. Zobaczysz, jak niewiele zmienia się w komponentach, gdy cała logika siedzi w serwisie.

Angular 22 metody serwisu map i kopie obiektów filtry w computed formularz z walidacją effect i localStorage 90 min
CEL LEKCJI

Czego się dziś nauczysz

  • Dodasz do serwisu metody zmieniające dane, które pilnują reguł i zwracają komunikat błędu.
  • Zmienisz jedno wydarzenie w tablicy, tworząc nowy obiekt przez map i ....
  • Połączysz trzy filtry — z adresu, z wyszukiwarki i z checkboxa — w jednym computed.
  • Zbudujesz formularz nowego wydarzenia z walidacją i przejściem na jego stronę.
  • Zapamiętasz stan aplikacji w localStorage za pomocą effect w serwisie.

Przygotowanie: lekcja 41 (część 1 projektu), lekcje 24–26 (formularze i walidacja), 31 (map z kopią obiektu), 35 (parametry zapytania), 37 (effect, localStorage). Przewidywany czas: 2 lekcje (90 min).

TEORIA

Zadanie: aplikacja, która zmienia dane

Rozbuduj aplikację z części 1 tak, żeby:

  • na stronie szczegółów można było się zapisać (imię co najmniej 3 znaki, bez powtórzeń, nie ponad limit) i wypisać dowolną osobę;
  • w menu był licznik wszystkich zapisów, zawsze aktualny;
  • lista miała filtr kategorii w adresie (?kategoria=gry), wyszukiwarkę po tytule i opcję „tylko z wolnymi miejscami”;
  • pełne wydarzenia były wyróżnione, a kategorie miały własne kolory;
  • był formularz nowego wydarzenia z walidacją, który po zapisie przenosi na stronę nowego wydarzenia;
  • zmiany przetrwały odświeżenie strony, a jeden przycisk przywracał dane z serwera.
KrokPlikiCo dochodzi
1serwis, szczegóły, szkieletmetody zapisz, wypisz, licznik zapisów; formularz zapisu
2lista, kartafiltr kategorii w adresie, wyszukiwarka, „tylko wolne”; kolory i oznaczenie PEŁNE
3nowy komponent, trasy, serwis, szkieletstrona dodaj i metoda dodaj
4serwis, szkieletzapis w localStorage i przywracanie danych

Laboratorium

Folder projekt-2 to projekt-1 ze zmianami z tej lekcji (plik wydarzenie.ts się nie zmienia). Rejestracja: { path: 'projekt-2', component: Demo, children: trasy }. Pliki w laboratorium pokazują stan po kroku 4; zmiany z wcześniejszych kroków są w lekcji pokazane jako fragmenty do dopisania. Budując projekt samodzielnie, pracuj dalej na swoim folderze z części 1.

KROK 1

Zapis na wydarzenie i licznik w menu

Serwis dostaje computed z liczbą wszystkich zapisów i dwie metody zmieniające dane. Metoda zapisz sprawdza reguły i zwraca komunikat błędu albo pusty tekst, gdy wszystko się udało.

src/app/projekt-2/wydarzenia-serwis.ts — fragment do dopisania (krok 1)
    readonly wszystkichZapisow = computed(() => {
        let suma = 0;
        for (const w of this._lista()) {
            suma += w.zapisani.length;
        }
        return suma;
    });

    zapisz(id: number, imie: string): string {
        const w = this.znajdz(id);
        const czyste = imie.trim();
        if (!w) {
            return 'Nie ma takiego wydarzenia.';
        }
        if (w.zapisani.length >= w.limit) {
            return 'Brak wolnych miejsc.';
        }
        const powtorka = w.zapisani.find(o => o.toLowerCase() === czyste.toLowerCase());
        if (powtorka !== undefined) {
            return czyste + ' jest już na liście.';
        }
        this._lista.update(lista =>
            lista.map(x => (x.id === id ? { ...x, zapisani: [...x.zapisani, czyste] } : x))
        );
        return '';
    }

    wypisz(id: number, nr: number): void {
        this._lista.update(lista =>
            lista.map(x => (x.id === id ? { ...x, zapisani: x.zapisani.filter((o, i) => i !== nr) } : x))
        );
    }

Do importu z @angular/core dopisz computed. Zmiana jednego wydarzenia to wzór z lekcji 31: map tworzy nową tablicę, a w miejscu zmienianego wydarzenia — nowy obiekt { ...x, zapisani: […] } z nową tablicą osób. Dzięki temu każdy computed i każda karta dowiadują się o zmianie.

Strona szczegółów dostaje formularz z walidacją (lekcja 26) i przyciski „wypisz”. Komponent tylko wywołuje metody serwisu i pokazuje komunikat.

src/app/projekt-2/szczegoly.ts — pełny plik
import { Component, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { DatePipe } from '@angular/common';
import { FormsModule, NgForm } from '@angular/forms';
import { toSignal } from '@angular/core/rxjs-interop';
import { WydarzeniaSerwis } from './wydarzenia-serwis';

@Component({
    selector: 'app-szczegoly',
    imports: [RouterLink, DatePipe, FormsModule],
    templateUrl: './szczegoly.html'
})
export class Szczegoly {
    private readonly trasa = inject(ActivatedRoute);
    private readonly serwis = inject(WydarzeniaSerwis);

    readonly parametry = toSignal(this.trasa.paramMap);
    readonly id = computed(() => Number(this.parametry()?.get('id')));
    readonly wydarzenie = computed(() => this.serwis.znajdz(this.id()));
    readonly ladowanie = this.serwis.ladowanie;

    readonly wolne = computed(() => {
        const w = this.wydarzenie();
        return w ? w.limit - w.zapisani.length : 0;
    });

    readonly imie = signal('');
    readonly komunikat = signal('');
    readonly sukces = signal(false);

    zapisz(f: NgForm): void {
        if (f.invalid) {
            f.form.markAllAsTouched();
            return;
        }
        const blad = this.serwis.zapisz(this.id(), this.imie());
        this.sukces.set(blad === '');
        this.komunikat.set(blad === '' ? 'Zapisano: ' + this.imie().trim() : blad);
        if (blad === '') {
            this.imie.set('');
            f.form.markAsUntouched();
        }
    }

    wypisz(nr: number): void {
        this.serwis.wypisz(this.id(), nr);
        this.komunikat.set('');
    }
}
src/app/projekt-2/szczegoly.html — pełny plik
<a class="btn btn-sm btn-link px-0 mb-2" routerLink="../../wydarzenia">← Wszystkie wydarzenia</a>

@if (wydarzenie(); as w) {
  <div class="row g-4">
    <div class="col-md-7">
      <div class="card">
        <div class="card-body">
          <span class="badge text-bg-secondary">{{ w.kategoria }}</span>
          <h3 class="card-title mt-2">{{ w.tytul }}</h3>
          <p class="card-text">{{ w.data | date:'EEEE, d MMMM y' }} · sala {{ w.sala }}</p>
          <h6>Zapisani ({{ w.zapisani.length }} / {{ w.limit }})</h6>
          <ul class="list-group">
            @for (osoba of w.zapisani; track $index; let nr = $index) {
              <li class="list-group-item d-flex justify-content-between align-items-center">
                {{ nr + 1 }}. {{ osoba }}
                <button type="button" class="btn btn-sm btn-outline-danger" (click)="wypisz(nr)">wypisz</button>
              </li>
            } @empty {
              <li class="list-group-item text-secondary">Nikt się jeszcze nie zapisał.</li>
            }
          </ul>
        </div>
      </div>
    </div>

    <div class="col-md-5">
      <div class="card p-3">
        <h5>Zapisz się</h5>
        @if (wolne() > 0) {
          <form #f="ngForm" (ngSubmit)="zapisz(f)">
            <div class="mb-2">
              <input name="imie" class="form-control" placeholder="Imię i nazwisko"
                     required minlength="3" [(ngModel)]="imie" #i="ngModel"
                     [class.is-invalid]="i.invalid && i.touched">
              <div class="invalid-feedback">Podaj co najmniej 3 znaki.</div>
            </div>
            <button class="btn btn-primary" type="submit">Zapisz (wolne: {{ wolne() }})</button>
          </form>
        } @else {
          <div class="alert alert-secondary mb-0">Brak wolnych miejsc.</div>
        }
        @if (komunikat()) {
          <div class="alert mt-3 mb-0" [class.alert-success]="sukces()" [class.alert-danger]="!sukces()">
            {{ komunikat() }}
          </div>
        }
      </div>
    </div>
  </div>
} @else if (ladowanie()) {
  <p>Wczytywanie…</p>
} @else {
  <div class="alert alert-warning">Nie ma wydarzenia o numerze {{ parametry()?.get('id') }}.</div>
}
src/app/projekt-2/demo.ts — pełny plik
import { Component, inject } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { WydarzeniaSerwis } from './wydarzenia-serwis';

@Component({
    selector: 'app-demo',
    imports: [RouterOutlet, RouterLink, RouterLinkActive],
    templateUrl: './demo.html'
})
export class Demo {
    readonly serwis = inject(WydarzeniaSerwis);

    constructor() {
        this.serwis.wczytaj();
    }
}
src/app/projekt-2/demo.html — wersja po kroku 1
<div class="p-3" style="max-width: 60rem">
  <nav class="navbar navbar-expand bg-dark rounded px-3 mb-3" data-bs-theme="dark">
    <span class="navbar-brand">Klub ZSMI</span>
    <div class="navbar-nav me-auto">
      <a class="nav-link" routerLink="wydarzenia" routerLinkActive="active">Wydarzenia</a>
    </div>
    <span class="badge text-bg-warning">Zapisów: {{ serwis.wszystkichZapisow() }}</span>
  </nav>

  <router-outlet />
</div>
Turniej retro: zapis „Ala”, potem „Zenek”
Zapisz się
[Ala                ]  [Zapisz (wolne: 5)]
Ala jest już na liście.                        (czerwony)
…
Zapisano: Zenek                                (zielony)
Menu: Zapisów: 10 → 11

W demo.ts pole serwis nie jest już private, bo szablon czyta z niego licznik. Wróć do listy: karta Turnieju retro pokazuje nową liczbę wolnych miejsc. Kliknij „wypisz” — wszystko cofa się w obu miejscach.

Zatrzymaj się

Reguły zapisu (limit, powtórzenia) są w serwisie, a nie w formularzu. Gdyby doszedł drugi formularz zapisu na innej stronie, ile miejsc musiałbyś zmienić przy zmianie reguł?

KROK 2

Filtry, wyszukiwarka i wyróżnienia

Lista dostaje trzy filtry, które działają razem: kategoria z adresu (lekcja 35), tekst z wyszukiwarki i checkbox. Jeden computed łączy wszystkie trzy warunki operatorem &&. Karta dostaje kolor kategorii i oznaczenie PEŁNE.

src/app/projekt-2/lista.ts — pełny plik
import { Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { WydarzeniaSerwis } from './wydarzenia-serwis';
import { Karta } from './karta';

@Component({
    selector: 'app-lista',
    imports: [Karta, FormsModule, RouterLink],
    templateUrl: './lista.html'
})
export class Lista {
    readonly serwis = inject(WydarzeniaSerwis);
    private readonly trasa = inject(ActivatedRoute);

    readonly kategorie = ['gry', 'technika', 'sztuka'];
    readonly zapytanie = toSignal(this.trasa.queryParamMap);
    readonly kategoria = computed(() => this.zapytanie()?.get('kategoria') ?? '');

    readonly szukaj = signal('');
    readonly tylkoWolne = signal(false);

    readonly widoczne = computed(() => {
        const tekst = this.szukaj().trim().toLowerCase();
        return this.serwis.lista().filter(w =>
            (this.kategoria() === '' || w.kategoria === this.kategoria()) &&
            (tekst === '' || w.tytul.toLowerCase().includes(tekst)) &&
            (!this.tylkoWolne() || w.zapisani.length < w.limit)
        );
    });
}
src/app/projekt-2/lista.html — pełny plik
<h4 class="mb-3">Nadchodzące wydarzenia</h4>

<div class="d-flex flex-wrap gap-2 align-items-center mb-3">
  <div class="btn-group btn-group-sm">
    <a class="btn btn-outline-primary" routerLink="." [class.active]="kategoria() === ''">wszystkie</a>
    @for (k of kategorie; track k) {
      <a class="btn btn-outline-primary" routerLink="." [queryParams]="{ kategoria: k }"
         [class.active]="kategoria() === k">{{ k }}</a>
    }
  </div>
  <input name="szukaj" class="form-control form-control-sm" style="width: 14rem"
         placeholder="Szukaj w tytułach…" [(ngModel)]="szukaj">
  <div class="form-check mb-0">
    <input id="wolne" name="wolne" class="form-check-input" type="checkbox" [(ngModel)]="tylkoWolne">
    <label class="form-check-label" for="wolne">tylko z wolnymi miejscami</label>
  </div>
</div>

@if (serwis.ladowanie()) {
  <p><span class="spinner-border spinner-border-sm"></span> Wczytywanie…</p>
} @else if (serwis.blad()) {
  <div class="alert alert-danger">{{ serwis.blad() }}</div>
} @else {
  <div class="row g-3">
    @for (w of widoczne(); track w.id) {
      <div class="col-md-4">
        <app-karta [wydarzenie]="w" />
      </div>
    } @empty {
      <p class="text-secondary">Żadne wydarzenie nie pasuje do filtrów.</p>
    }
  </div>
}
src/app/projekt-2/karta.ts — pełny plik
import { Component, computed, input } from '@angular/core';
import { DatePipe } from '@angular/common';
import { RouterLink } from '@angular/router';
import { Wydarzenie } from './wydarzenie';

@Component({
    selector: 'app-karta',
    imports: [DatePipe, RouterLink],
    templateUrl: './karta.html'
})
export class Karta {
    readonly wydarzenie = input.required<Wydarzenie>();

    readonly wolne = computed(() => this.wydarzenie().limit - this.wydarzenie().zapisani.length);
    readonly procent = computed(() =>
        Math.round(this.wydarzenie().zapisani.length / this.wydarzenie().limit * 100)
    );
    readonly kolor = computed(() => {
        const k = this.wydarzenie().kategoria;
        if (k === 'gry') {
            return 'text-bg-primary';
        } else if (k === 'technika') {
            return 'text-bg-success';
        }
        return 'text-bg-info';
    });
}
src/app/projekt-2/karta.html — pełny plik
<div class="card h-100" [class.opacity-75]="wolne() === 0">
  <div class="card-body">
    <span class="badge mb-2" [class]="kolor()">{{ wydarzenie().kategoria }}</span>
    @if (wolne() === 0) { <span class="badge text-bg-danger mb-2 ms-1">PEŁNE</span> }
    <h5 class="card-title">{{ wydarzenie().tytul }}</h5>
    <p class="card-text mb-2">
      {{ wydarzenie().data | date:'EEEE, d MMMM' }}<br>
      sala {{ wydarzenie().sala }}
    </p>
    <div class="progress mb-2" style="height: 6px">
      <div class="progress-bar" [class.bg-danger]="wolne() === 0" [style.width.%]="procent()"></div>
    </div>
    <p class="small mb-0">Zapisanych: {{ wydarzenie().zapisani.length }} / {{ wydarzenie().limit }}</p>
  </div>
  <div class="card-footer bg-transparent">
    <a class="btn btn-sm btn-outline-primary" [routerLink]="['../wydarzenie', wydarzenie().id]">
      {{ wolne() > 0 ? 'Szczegóły i zapis' : 'Szczegóły' }}
    </a>
  </div>
</div>
?kategoria=technika + „tylko z wolnymi miejscami”
[wszystkie] [gry] [technika] [sztuka]   [Szukaj w tytułach…]   ☑ tylko z wolnymi miejscami
Warsztat druku 3D     Hackathon klasowy

Każdy z trzech warunków ma postać „filtr wyłączony albo element pasuje”, np. (tekst === '' || w.tytul.toLowerCase().includes(tekst)). Kolor kategorii wybiera computed w karcie i zwraca nazwę klasy Bootstrapa; [class.opacity-75] lekko wygasza pełne wydarzenie. Gdy nic nie pasuje, pojawia się komunikat z @empty.

Zatrzymaj się

Kategoria jest w adresie, a tekst wyszukiwarki — w zwykłym sygnale. Dlaczego? Kiedy tekst wyszukiwania też warto byłoby trzymać w adresie?

KROK 3

Dodawanie wydarzenia

Nowa strona z formularzem (lekcje 17–26): tytuł, kategoria z listy, data, sala i limit z zakresem. Serwis nadaje nowemu wydarzeniu kolejny numer, a po zapisie router przenosi użytkownika na stronę nowego wydarzenia (lekcja 34).

src/app/projekt-2/nowe-wydarzenie.ts — pełny plik
import { Component, inject, signal } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { WydarzeniaSerwis } from './wydarzenia-serwis';

@Component({
    selector: 'app-nowe-wydarzenie',
    imports: [FormsModule],
    templateUrl: './nowe-wydarzenie.html'
})
export class NoweWydarzenie {
    private readonly serwis = inject(WydarzeniaSerwis);
    private readonly router = inject(Router);
    private readonly trasa = inject(ActivatedRoute);

    readonly kategorie = ['gry', 'technika', 'sztuka'];
    readonly tytul = signal('');
    readonly kategoria = signal('');
    readonly data = signal('');
    readonly sala = signal('');
    readonly limit = signal<number | null>(10);

    zapisz(f: NgForm): void {
        if (f.invalid) {
            f.form.markAllAsTouched();
            return;
        }
        const id = this.serwis.dodaj({
            tytul: this.tytul().trim(),
            kategoria: this.kategoria(),
            data: this.data(),
            sala: this.sala().trim(),
            limit: Number(this.limit()),
        });
        this.router.navigate(['../wydarzenie', id], { relativeTo: this.trasa });
    }
}
src/app/projekt-2/nowe-wydarzenie.html — pełny plik
<h4 class="mb-3">Nowe wydarzenie</h4>
<form #f="ngForm" (ngSubmit)="zapisz(f)" style="max-width: 32rem">
  <div class="mb-3">
    <label class="form-label" for="tytul">Tytuł *</label>
    <input id="tytul" name="tytul" class="form-control" required minlength="3"
           [(ngModel)]="tytul" #t="ngModel" [class.is-invalid]="t.invalid && t.touched">
    <div class="invalid-feedback">Tytuł musi mieć co najmniej 3 znaki.</div>
  </div>

  <div class="row g-3 mb-3">
    <div class="col-6">
      <label class="form-label" for="kategoria">Kategoria *</label>
      <select id="kategoria" name="kategoria" class="form-select" required
              [(ngModel)]="kategoria" #k="ngModel" [class.is-invalid]="k.invalid && k.touched">
        <option value="" disabled>— wybierz —</option>
        @for (kat of kategorie; track kat) { <option [value]="kat">{{ kat }}</option> }
      </select>
      <div class="invalid-feedback">Wybierz kategorię.</div>
    </div>
    <div class="col-6">
      <label class="form-label" for="data">Data *</label>
      <input id="data" name="data" type="date" class="form-control" required
             [(ngModel)]="data" #d="ngModel" [class.is-invalid]="d.invalid && d.touched">
      <div class="invalid-feedback">Wybierz datę.</div>
    </div>
  </div>

  <div class="row g-3 mb-3">
    <div class="col-6">
      <label class="form-label" for="sala">Sala *</label>
      <input id="sala" name="sala" class="form-control" required
             [(ngModel)]="sala" #s="ngModel" [class.is-invalid]="s.invalid && s.touched">
      <div class="invalid-feedback">Podaj salę.</div>
    </div>
    <div class="col-6">
      <label class="form-label" for="limit">Limit miejsc (1–30) *</label>
      <input id="limit" name="limit" type="number" class="form-control" required min="1" max="30"
             [(ngModel)]="limit" #l="ngModel" [class.is-invalid]="l.invalid && l.touched">
      <div class="invalid-feedback">Limit od 1 do 30.</div>
    </div>
  </div>

  <button class="btn btn-success" type="submit">Dodaj wydarzenie</button>
</form>
src/app/projekt-2/trasy.ts — pełny plik
import { Routes } from '@angular/router';
import { Lista } from './lista';
import { Szczegoly } from './szczegoly';
import { NoweWydarzenie } from './nowe-wydarzenie';

export const trasy: Routes = [
    { path: '', redirectTo: 'wydarzenia', pathMatch: 'full' },
    { path: 'wydarzenia', component: Lista },
    { path: 'wydarzenie/:id', component: Szczegoly },
    { path: 'dodaj', component: NoweWydarzenie },
    { path: '**', redirectTo: 'wydarzenia' },
];
src/app/projekt-2/wydarzenia-serwis.ts — fragment do dopisania (krok 3)
    dodaj(dane: Omit<Wydarzenie, 'id' | 'zapisani'>): number {
        let najwiekszy = 0;
        for (const w of this._lista()) {
            if (w.id > najwiekszy) {
                najwiekszy = w.id;
            }
        }
        const noweId = najwiekszy + 1;
        this._lista.update(lista => [...lista, { ...dane, id: noweId, zapisani: [] }]);
        return noweId;
    }
src/app/projekt-2/demo.html — nowa pozycja menu (krok 3)
      <a class="nav-link" routerLink="dodaj" routerLinkActive="active">Dodaj wydarzenie</a>
po zapisaniu formularza — adres …/projekt-2/wydarzenie/7
← Wszystkie wydarzenia
[sztuka]
Kino klubowe
sobota, 12 grudnia 2026 · sala Aula
Zapisani (0 / 10)
Nikt się jeszcze nie zapisał.

Omit<Wydarzenie, 'id' | 'zapisani'> to typ „wydarzenie bez pól id i zapisani” — formularz nie zna numeru i nie ma listy osób, uzupełnia je serwis. Nowy numer to największy dotychczasowy plus jeden. Number(this.limit()) zamienia wartość pola liczbowego na liczbę (pole może zwrócić null, ale po walidacji wiemy, że jest wypełnione).

Zatrzymaj się

Odśwież stronę po dodaniu wydarzenia (bez kroku 4). Gdzie się podziało? Co trzeba zrobić, żeby zostało?

KROK 4

Zapamiętywanie w localStorage

Ostatni punkt zadania. effect w serwisie (lekcja 37) zapisuje całą listę przy każdej zmianie. wczytaj najpierw szuka danych w localStorage i dopiero gdy ich nie ma, pobiera plik z serwera. przywroc kasuje zapis i pobiera dane od nowa. Poniżej pełny serwis po wszystkich krokach.

src/app/projekt-2/wydarzenia-serwis.ts — pełny plik
import { Service, computed, effect, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Wydarzenie } from './wydarzenie';

const KLUCZ = 'klub-zsmi-wydarzenia';

@Service()
export class WydarzeniaSerwis {
    private readonly http = inject(HttpClient);

    private readonly _lista = signal<Wydarzenie[]>([]);
    readonly lista = this._lista.asReadonly();
    readonly ladowanie = signal(false);
    readonly blad = signal('');

    readonly wszystkichZapisow = computed(() => {
        let suma = 0;
        for (const w of this._lista()) {
            suma += w.zapisani.length;
        }
        return suma;
    });

    constructor() {
        effect(() => {
            if (this._lista().length > 0) {
                localStorage.setItem(KLUCZ, JSON.stringify(this._lista()));
            }
        });
    }

    wczytaj(): void {
        if (this._lista().length > 0 || this.ladowanie()) {
            return;
        }
        const zapisane = localStorage.getItem(KLUCZ);
        if (zapisane) {
            this._lista.set(JSON.parse(zapisane));
            return;
        }
        this.pobierzZSerwera();
    }

    przywroc(): void {
        localStorage.removeItem(KLUCZ);
        this._lista.set([]);
        this.pobierzZSerwera();
    }

    znajdz(id: number): Wydarzenie | undefined {
        return this._lista().find(w => w.id === id);
    }

    zapisz(id: number, imie: string): string {
        const w = this.znajdz(id);
        const czyste = imie.trim();
        if (!w) {
            return 'Nie ma takiego wydarzenia.';
        }
        if (w.zapisani.length >= w.limit) {
            return 'Brak wolnych miejsc.';
        }
        const powtorka = w.zapisani.find(o => o.toLowerCase() === czyste.toLowerCase());
        if (powtorka !== undefined) {
            return czyste + ' jest już na liście.';
        }
        this._lista.update(lista =>
            lista.map(x => (x.id === id ? { ...x, zapisani: [...x.zapisani, czyste] } : x))
        );
        return '';
    }

    wypisz(id: number, nr: number): void {
        this._lista.update(lista =>
            lista.map(x => (x.id === id ? { ...x, zapisani: x.zapisani.filter((o, i) => i !== nr) } : x))
        );
    }

    dodaj(dane: Omit<Wydarzenie, 'id' | 'zapisani'>): number {
        let najwiekszy = 0;
        for (const w of this._lista()) {
            if (w.id > najwiekszy) {
                najwiekszy = w.id;
            }
        }
        const noweId = najwiekszy + 1;
        this._lista.update(lista => [...lista, { ...dane, id: noweId, zapisani: [] }]);
        return noweId;
    }

    private pobierzZSerwera(): void {
        this.ladowanie.set(true);
        this.blad.set('');
        this.http.get<Wydarzenie[]>('/dane/projekt.json').subscribe({
            next: dane => {
                this._lista.set(dane);
                this.ladowanie.set(false);
            },
            error: () => {
                this.blad.set('Nie udało się wczytać wydarzeń.');
                this.ladowanie.set(false);
            }
        });
    }
}
src/app/projekt-2/demo.html — pełny plik
<div class="p-3" style="max-width: 60rem">
  <nav class="navbar navbar-expand bg-dark rounded px-3 mb-3" data-bs-theme="dark">
    <span class="navbar-brand">Klub ZSMI</span>
    <div class="navbar-nav me-auto">
      <a class="nav-link" routerLink="wydarzenia" routerLinkActive="active">Wydarzenia</a>
      <a class="nav-link" routerLink="dodaj" routerLinkActive="active">Dodaj wydarzenie</a>
    </div>
    <span class="badge text-bg-warning">Zapisów: {{ serwis.wszystkichZapisow() }}</span>
  </nav>

  <router-outlet />

  <p class="text-end mt-4">
    <button class="btn btn-sm btn-link text-secondary" (click)="serwis.przywroc()">Przywróć dane z serwera</button>
  </p>
</div>
po zapisie, dodaniu wydarzenia i F5
7 kart, Zapisów: 11 — wszystko na miejscu
F12 → Application → Local Storage → klub-zsmi-wydarzenia: [{"id":1,…}]
„Przywróć dane z serwera” → 6 kart, Zapisów: 10

Pobieranie z serwera przeniesiono do prywatnej metody pobierzZSerwera, bo korzystają z niej dwie metody: wczytaj i przywroc. Żaden komponent nie wie o istnieniu localStorage — gdyby dane miały trafiać do prawdziwego serwera, zmieniłby się tylko serwis. Gotowe: sprawdź wszystkie sześć punktów zadania.

Zatrzymaj się

effect zapisuje dane tylko wtedy, gdy lista nie jest pusta. Co by się stało przy starcie aplikacji bez tego warunku?

ELEMENTY WBUDOWANE

Zestawienie elementów

ElementZnaczenie w projekcie
lista.map(x => x.id === id ? { ...x, … } : x)zmiana jednego wydarzenia z nowym obiektem
[...x.zapisani, imie], filter((o, i) => i !== nr)dopisanie i usunięcie osoby
find(o => …), toLowerCase()szukanie powtórzeń bez względu na wielkość liter
a && b && c w filterkilka filtrów działających razem
Omit<Typ, 'pole'>typ bez wybranych pól
f.form.markAllAsTouched(), markAsUntouched()pokazanie błędów i czyszczenie stanu pól (lekcja 26)
router.navigate(['../wydarzenie', id], { relativeTo })przejście na stronę nowego wydarzenia
effect + JSON.stringify / JSON.parsezapamiętywanie listy w przeglądarce
localStorage.removeItem(klucz)usunięcie zapisu
private pobierzZSerwera()metoda pomocnicza dostępna tylko w serwisie
CZĘSTE BŁĘDY

Zanim utkniesz

ZapisProblem
Komponent zmienia dane wprost: w.zapisani.push(imie)Lista na stronie może się nawet zmienić, ale licznik w menu i karty — nie: sygnał nie wie o zmianie, bo tablica jest ta sama (lekcja 10). Zmiany tylko metodami serwisu, zawsze nowe obiekty.
Reguły zapisu sprawdzane w formularzuKażdy nowy formularz musi je powtórzyć, a łatwo o różnice. Reguły w serwisie, formularz tylko pokazuje komunikat.
Trzy osobne computed dla trzech filtrówFiltry nie działają razem. Jeden computed łączy wszystkie warunki przez &&.
Nowe id liczone jako lista.length + 1Po usunięciu wydarzenia numery się powtarzają, a track i linki do szczegółów zaczynają się mylić. Bierz największy numer plus jeden.
effect zapisujący także pustą listęPrzy starcie zapis w localStorage jest nadpisywany pustą tablicą, zanim cokolwiek się wczyta — dane znikają po odświeżeniu.
JSON.parse(localStorage.getItem(KLUCZ))TS2345: Argument of type 'string | null’ is not assignable to parameter of type 'string’. Najpierw sprawdź, czy jest co wczytać.
ZADANIA

Zadania

ZAD 1Licznik wolnych miejsc w menu★☆☆

Obok licznika zapisów w menu dodaj drugi: „Wolnych miejsc: X” — suma wolnych miejsc we wszystkich wydarzeniach.

Kryterium sukcesu: licznik zmienia się po każdym zapisie, wypisaniu i dodaniu wydarzenia.

Podpowiedź

Kolejny computed w serwisie, wzorowany na wszystkichZapisow.

ZAD 2Usuwanie wydarzenia★☆☆

Dodaj w serwisie metodę usun(id) i przycisk „Usuń wydarzenie” na stronie szczegółów. Po usunięciu użytkownik wraca do listy.

Kryterium sukcesu: usunięte wydarzenie znika z listy i z licznika zapisów, a po odświeżeniu nie wraca.

Podpowiedź

filter w serwisie i router.navigate(['../../wydarzenia'], { relativeTo: this.trasa }) w komponencie.

ZAD 3Potwierdzenie usunięcia★★☆

Przed usunięciem wydarzenia (zadanie 2) pokaż okno z lekcji 39: „Czy na pewno usunąć tytuł? Zapisanych osób: X”. Usunięcie dopiero po „Tak”.

Kryterium sukcesu: „Nie”, krzyżyk i kliknięcie w tło zamykają okno bez usuwania; po „Tak” wydarzenie znika.

Podpowiedź

Skopiuj komponent Okno do projektu; sygnał pytanie (true/false) w szczegółach i @if wokół okna.

ZAD 4Klasa przy zapisie★★☆

Rozbuduj zapis: oprócz imienia uczeń wybiera klasę z listy (1TP–5TP). Na liście zapisanych pokazuj „Imię (klasa)”. Zmień model tak, żeby zapisani było tablicą obiektów { imie, klasa }.

Kryterium sukcesu: formularz wymaga obu pól; lista zapisanych pokazuje klasę; kontrola powtórzeń dalej działa.

Podpowiedź

Zmień interfejs i JSON — kompilator wskaże wszystkie miejsca do poprawienia. Po zmianie modelu kliknij „Przywróć dane z serwera”, żeby wyczyścić stary zapis.

ZAD 5Moje zapisy★★★

Dodaj stronę „Moje zapisy”: pole „Twoje imię” i lista wydarzeń, na które ta osoba jest zapisana, z datami i przyciskiem „wypisz się”. Imię zapamiętaj w localStorage.

Kryterium sukcesu: po wpisaniu imienia widać właściwe wydarzenia; wypisanie zmienia też listę w szczegółach i licznik w menu; imię zostaje po F5.

Podpowiedź

Nowa trasa, nowy komponent, computed z filter(w => w.zapisani.includes(imie)). Numer osoby do wypisania: w.zapisani.indexOf(imie).

ZAD 6Edycja wydarzenia★★★

Na stronie szczegółów dodaj przycisk „Edytuj”, który prowadzi do edytuj/:id. Strona edycji to ten sam formularz co przy dodawaniu, wypełniony danymi wydarzenia. Zapis zmienia wydarzenie w serwisie (nowa metoda zmien) i wraca do szczegółów. Limitu nie można zmniejszyć poniżej liczby zapisanych osób.

Kryterium sukcesu: po edycji szczegóły, karta i licznik pokazują nowe dane; za mały limit daje komunikat z serwisu i nie zapisuje zmian.

Podpowiedź

Wypełnienie formularza: w konstruktorze strony edycji effect, który po znalezieniu wydarzenia ustawia sygnały pól. Metoda zmien(id, dane) zwraca komunikat błędu jak zapisz.

PODSUMOWANIE

Co trzeba zapamiętać

  • Zmiany danych przechodzą przez metody serwisu, które pilnują reguł i tworzą nowe obiekty.
  • Dzięki jednemu źródłu danych licznik w menu, karty i szczegóły zawsze się zgadzają.
  • Kilka filtrów działa razem w jednym computed; filtr, który ma przetrwać w linku, trzymasz w adresie.
  • effect w serwisie zapisuje stan w localStorage, a komponenty nic o tym nie wiedzą.
  • Dobrze podzielona aplikacja rośnie przez dopisywanie metod i stron, a nie przez przepisywanie istniejących.

Sprawdź się: który jeden plik musiałbyś zmienić, gdyby szkoła kupiła prawdziwy serwer z bazą danych, i dlaczego reszta aplikacji mogłaby zostać bez zmian?

Dokumentacja: angular.dev — dokumentacja Angulara.

Postęp zapisuje się w tej przeglądarce.