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

Projekt — część 1: przeglądanie wydarzeń

Czas połączyć wszystko w jedną aplikację: zapisy na wydarzenia klubu. W tej części zbudujesz przeglądanie — dane z pliku JSON, serwis, router z parametrem, karty z wejściem i daty po polsku. Pracujesz krokami; po każdym kroku aplikacja się kompiluje i działa.

Angular 22 projekt model serwis router input pipe date 90 min
CEL LEKCJI

Czego się dziś nauczysz

  • Zaplanujesz podział aplikacji na pliki: model, dane, serwis, strony i klocki.
  • Pobierzesz dane z JSON-a w serwisie, który jest jedynym źródłem danych.
  • Zbudujesz szkielet z menu i <router-outlet /> oraz stronę z listą kart.
  • Utworzysz komponent-klocek, który dostaje wydarzenie przez input.required.
  • Dodasz stronę szczegółów z parametrem :id i obsługą ładowania oraz złego numeru.
  • Zbudujesz aplikację krokami, z których każdy kończy się działającym programem.

Przygotowanie: cały kurs, szczególnie lekcja 16 (pipe date po polsku), lekcje 29–30 (wejścia), 33 (serwis), 34–36 (router, parametr trasy, HttpClient). Przewidywany czas: 2 lekcje (90 min).

TEORIA

Zadanie: zapisy na wydarzenia klubu

Klub informatyczny chce aplikację, w której uczniowie przeglądają nadchodzące wydarzenia i zapisują się na nie. W tej części zrobimy przeglądanie:

  • lista wydarzeń pobierana z pliku /dane/projekt.json, z komunikatem w czasie ładowania i przy błędzie;
  • każde wydarzenie jako karta: kategoria, tytuł, data po polsku, sala, pasek zapełnienia i liczba wolnych miejsc;
  • strona szczegółów pod adresem wydarzenie/:id z listą zapisanych osób i komunikatem dla nieistniejącego numeru;
  • menu u góry i powrót ze szczegółów do listy.
PlikRolaKrok
wydarzenie.tsmodel: interfejs Wydarzenie1
public/dane/projekt.jsondane „z serwera”1
wydarzenia-serwis.tspobiera i przechowuje wydarzenia; jedyne źródło danych2
demo.ts, demo.htmlszkielet: menu i <router-outlet />; uruchamia wczytywanie2
trasy.tsadresy stron2, 4
lista.ts, lista.htmlstrona z listą wydarzeń2, 3
karta.ts, karta.htmlklocek: jedna karta wydarzenia3
szczegoly.ts, szczegoly.htmlstrona szczegółów z parametrem id4

Zasada porządkująca projekt

Dane ma tylko serwis. Strony pytają serwis o dane i je wyświetlają; karta dostaje jedno wydarzenie przez input i niczego nie zmienia. Dzięki temu w części 2, gdy dojdą zapisy, zmieni się głównie serwis.

Laboratorium

Folder projekt-1 zawiera stan po kroku 4. Rejestrujesz go jak przykłady z routerem: { path: 'projekt-1', component: Demo, children: trasy }. Plik projekt.json z folderu laboratorium-public-dane kopiujesz do public/dane. Daty po polsku wymagają ustawień z lekcji 16 (LOCALE_ID i registerLocaleData). Budując projekt sam, dopisuj pliki krok po kroku.

KROK 1

Model i dane

Zaczynamy od tego, jak wygląda jedno wydarzenie. Interfejs to umowa dla całej aplikacji: serwis, lista, karta i szczegóły będą mówić o tym samym kształcie danych. Plik JSON musi do tej umowy pasować.

src/app/projekt-1/wydarzenie.ts — pełny plik
export interface Wydarzenie {
    id: number;
    tytul: string;
    kategoria: string;
    data: string;          // RRRR-MM-DD
    sala: string;
    limit: number;
    zapisani: string[];
}
public/dane/projekt.json — nowy plik
[
  { "id": 1, "tytul": "Turniej retro", "kategoria": "gry", "data": "2026-10-16", "sala": "12", "limit": 8, "zapisani": ["Ala", "Bartek", "Celina"] },
  { "id": 2, "tytul": "Noc robotów", "kategoria": "technika", "data": "2026-10-24", "sala": "7", "limit": 4, "zapisani": ["Darek", "Ela", "Filip", "Gosia"] },
  { "id": 3, "tytul": "Warsztat druku 3D", "kategoria": "technika", "data": "2026-11-04", "sala": "14", "limit": 5, "zapisani": ["Hubert"] },
  { "id": 4, "tytul": "Liga planszówek", "kategoria": "gry", "data": "2026-11-13", "sala": "Biblioteka", "limit": 10, "zapisani": [] },
  { "id": 5, "tytul": "Grafika komputerowa", "kategoria": "sztuka", "data": "2026-11-20", "sala": "15", "limit": 6, "zapisani": ["Iga", "Jan"] },
  { "id": 6, "tytul": "Hackathon klasowy", "kategoria": "technika", "data": "2026-12-04", "sala": "Aula", "limit": 20, "zapisani": [] }
]

Data jest tekstem w formacie RRRR-MM-DD (lekcja 23): łatwo ją zapisać w JSON-ie i sformatować pipe’em date. zapisani: string[] to tablica imion. Komentarz // RRRR-MM-DD w interfejsie przypomina, jakiego formatu się spodziewamy.

Sprawdź

Otwórz w przeglądarce localhost:4200/dane/projekt.json — powinno być sześć wydarzeń. Jeśli widzisz stronę aplikacji albo błąd, plik leży w złym miejscu. Sprawdź też, czy każde wydarzenie ma wszystkie pola z interfejsu: co by się stało w karcie, gdyby jednemu brakowało pola limit?

KROK 2

Serwis, szkielet z menu i prosta lista

Serwis z lekcji 36: pobiera dane raz i trzyma je w prywatnym sygnale udostępnionym przez asReadonly(). Szkielet aplikacji to menu i <router-outlet />; to on uruchamia wczytywanie. Lista jest na razie zwykłą listą tekstową — karty dojdą w kroku 3.

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

@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('');

    wczytaj(): void {
        if (this._lista().length > 0 || this.ladowanie()) {
            return;
        }
        this.ladowanie.set(true);
        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);
            }
        });
    }

    znajdz(id: number): Wydarzenie | undefined {
        return this._lista().find(w => w.id === id);
    }
}
src/app/projekt-1/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 {
    private readonly serwis = inject(WydarzeniaSerwis);

    constructor() {
        this.serwis.wczytaj();
    }
}
src/app/projekt-1/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">
      <a class="nav-link" routerLink="wydarzenia" routerLinkActive="active">Wydarzenia</a>
    </div>
  </nav>
  <router-outlet />
</div>
src/app/projekt-1/trasy.ts — wersja tymczasowa (krok 2)
import { Routes } from '@angular/router';
import { Lista } from './lista';

export const trasy: Routes = [
    { path: '', redirectTo: 'wydarzenia', pathMatch: 'full' },
    { path: 'wydarzenia', component: Lista },
    { path: '**', redirectTo: 'wydarzenia' },
];
src/app/projekt-1/lista.ts — wersja tymczasowa (krok 2)
import { Component, inject } from '@angular/core';
import { WydarzeniaSerwis } from './wydarzenia-serwis';

@Component({
    selector: 'app-lista',
    imports: [],
    templateUrl: './lista.html'
})
export class Lista {
    readonly serwis = inject(WydarzeniaSerwis);
}
src/app/projekt-1/lista.html — wersja tymczasowa (krok 2)
<h4 class="mb-3">Nadchodzące wydarzenia</h4>

@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 {
  <ul class="list-group">
    @for (w of serwis.lista(); track w.id) {
      <li class="list-group-item">{{ w.tytul }} — {{ w.data }}, sala {{ w.sala }}</li>
    }
  </ul>
}
wynik po kroku 2 — adres …/projekt-1/wydarzenia
█ Klub ZSMI   [Wydarzenia]
Nadchodzące wydarzenia
Turniej retro — 2026-10-16, sala 12
Noc robotów — 2026-10-24, sala 7
… (sześć wierszy)

Metoda znajdz(id) przyda się w kroku 4. Wywołana wewnątrz computed odczytuje sygnał _lista, więc computed przeliczy się, gdy dane dotrą z serwera — także wtedy, gdy ktoś wejdzie od razu na adres szczegółów. Zmień na próbę adres pliku w serwisie na zły — pojawi się czerwony komunikat.

Zatrzymaj się

Wczytywanie uruchamia szkielet, a nie lista. Dlaczego to lepsze miejsce? Pomyśl o wejściu bezpośrednio na stronę szczegółów.

KROK 3

Karta wydarzenia

Komponent-klocek app-karta: dostaje jedno wydarzenie przez input.required, sam liczy wolne miejsca i procent zapełnienia, formatuje datę po polsku i linkuje do szczegółów. Lista zamienia wiersze na siatkę kart.

src/app/projekt-1/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)
    );
}
src/app/projekt-1/karta.html — pełny plik
<div class="card h-100">
  <div class="card-body">
    <span class="badge text-bg-secondary mb-2">{{ wydarzenie().kategoria }}</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" [class.text-danger]="wolne() === 0">
      {{ wolne() > 0 ? 'Wolnych miejsc: ' + wolne() : 'Brak miejsc' }}
    </p>
  </div>
  <div class="card-footer bg-transparent">
    <a class="btn btn-sm btn-outline-primary" [routerLink]="['../wydarzenie', wydarzenie().id]">Szczegóły</a>
  </div>
</div>
src/app/projekt-1/lista.ts — pełny plik
import { Component, inject } from '@angular/core';
import { WydarzeniaSerwis } from './wydarzenia-serwis';
import { Karta } from './karta';

@Component({
    selector: 'app-lista',
    imports: [Karta],
    templateUrl: './lista.html'
})
export class Lista {
    readonly serwis = inject(WydarzeniaSerwis);
}
src/app/projekt-1/lista.html — pełny plik
<h4 class="mb-3">Nadchodzące wydarzenia</h4>

@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 serwis.lista(); track w.id) {
      <div class="col-md-4">
        <app-karta [wydarzenie]="w" />
      </div>
    }
  </div>
}
wynik po kroku 3 (pierwsze dwie karty)
[gry]                           [technika]
Turniej retro                   Noc robotów
piątek, 16 października         sobota, 24 października
sala 12                         sala 7
▓▓▓▓▓▓░░░░░░░░                  ▓▓▓▓▓▓▓▓▓▓▓▓▓▓ (czerwony)
Wolnych miejsc: 5               Brak miejsc
[Szczegóły]                     [Szczegóły]

date:'EEEE, d MMMM' to pełna nazwa dnia, dzień i nazwa miesiąca (lekcja 16). Link w karcie to ['../wydarzenie', id]: karta stoi na stronie wydarzenia, więc .. cofa o jedno piętro i dokłada wydarzenie/3. Przycisk „Szczegóły” na razie prowadzi z powrotem na listę — trasy szczegółów jeszcze nie ma, więc działa **.

Zatrzymaj się

Karta nie wstrzykuje serwisu — dostaje wszystko przez input. Jaką to daje korzyść, gdy zechcesz użyć tej samej karty na innej stronie, np. „Moje zapisy”?

KROK 4

Strona szczegółów

Strona szczegółów odczytuje id z adresu (lekcja 35) i pyta serwis o wydarzenie. Obsługuje trzy sytuacje: wydarzenie jest, dane jeszcze się wczytują, wydarzenia o takim numerze nie ma. Do tras dochodzi wydarzenie/:id.

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

@Component({
    selector: 'app-szczegoly',
    imports: [RouterLink, DatePipe],
    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;
}
src/app/projekt-1/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="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>
      <ol class="mb-0">
        @for (osoba of w.zapisani; track $index) {
          <li>{{ osoba }}</li>
        } @empty {
          <li class="text-secondary">Nikt się jeszcze nie zapisał.</li>
        }
      </ol>
    </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-1/trasy.ts — pełny plik
import { Routes } from '@angular/router';
import { Lista } from './lista';
import { Szczegoly } from './szczegoly';

export const trasy: Routes = [
    { path: '', redirectTo: 'wydarzenia', pathMatch: 'full' },
    { path: 'wydarzenia', component: Lista },
    { path: 'wydarzenie/:id', component: Szczegoly },
    { path: '**', redirectTo: 'wydarzenia' },
];
…/projekt-1/wydarzenie/2
← Wszystkie wydarzenia
[technika]
Noc robotów
sobota, 24 października 2026 · sala 7
Zapisani (4 / 4)
1. Darek  2. Ela  3. Filip  4. Gosia

Link powrotu ../../wydarzenia cofa o dwa piętra (z wydarzenie/2) i dokłada wydarzenia. Wpisz w adresie …/wydarzenie/77 — żółty komunikat. Odśwież stronę na szczegółach — przez ułamek sekundy widać „Wczytywanie…”, potem kartę. Część 1 gotowa: sprawdź listę wymagań z początku lekcji punkt po punkcie.

Zatrzymaj się

Kolejność bloków to @if (wydarzenie()), potem @else if (ladowanie()), potem @else. Co zobaczyłby użytkownik przy odświeżeniu strony, gdyby gałęzi z ładowaniem w ogóle nie było?

ELEMENTY WBUDOWANE

Zestawienie elementów

ElementGdzie w projekcieLekcja
interface z exportmodel Wydarzenie30
@Service(), inject, asReadonly()WydarzeniaSerwis33
HttpClient.get, subscribe({ next, error })serwis — wczytaj()36
Routes, redirectTo, **, routerLinkActivetrasy.ts, szkielet34
:id, toSignal(paramMap)strona szczegółów35
input.required, computedkarta29–30, 11
date:'EEEE, d MMMM'karta i szczegóły16
[style.width.%], [class.bg-danger]pasek zapełnienia15
@if … @else if … @else, @for … @emptylista i szczegóły12–13
CZĘSTE BŁĘDY

Zanim utkniesz

SytuacjaProblem
Dane wpisane na sztywno w komponencie listyLista działa, ale szczegóły i inne strony nie mają skąd wziąć danych. Jedno źródło danych — serwis.
Karta wstrzykuje serwis i sama szuka wydarzeniaDziała, ale karta przestaje być klockiem: nie da się jej użyć z innymi danymi. Karta dostaje dane przez input.
Wczytywanie tylko w liście, wejście wprost na …/wydarzenie/3Szczegóły na zawsze pokazują „nie ma wydarzenia”, bo nikt nie pobrał danych. Wczytuj w szkielecie.
Brak gałęzi ladowanie() w szczegółachPo odświeżeniu strony przez chwilę miga „Nie ma wydarzenia o numerze 3”, choć wydarzenie istnieje.
Interfejs i JSON niezgodne (np. miejsca zamiast limit)TypeScript tego nie wykryje — JSON przychodzi w trakcie działania. Na ekranie pojawia się NaN albo puste pola.
Daty po angielsku („Friday, October 16”)Brak polskich ustawień z lekcji 16: registerLocaleData(localePl) i LOCALE_ID w app.config.ts.
ZADANIA

Zadania

ZAD 1Siódme wydarzenie★☆☆

Dopisz do projekt.json własne wydarzenie w kategorii „sztuka”, z dwiema zapisanymi osobami.

Kryterium sukcesu: po odświeżeniu na liście jest siedem kart, a szczegóły nowego wydarzenia działają.

Podpowiedź

Przecinek między obiektami w tablicy, brak przecinka po ostatnim, unikalne id.

ZAD 2Godzina wydarzenia★☆☆

Dodaj do modelu i pliku JSON pole godzina (tekst „HH:MM”) i pokaż je na karcie oraz w szczegółach.

Kryterium sukcesu: każda karta pokazuje godzinę; po dopisaniu pola do interfejsu kompilator nie zgłasza błędów, a w JSON-ie każde wydarzenie ma godzinę.

Podpowiedź

Zacznij od interfejsu, potem JSON, na końcu szablony.

ZAD 3Podsumowanie pod listą★★☆

Pod siatką kart pokaż podsumowanie: liczba wydarzeń, łączna liczba wolnych miejsc i liczba wydarzeń bez miejsc.

Kryterium sukcesu: liczby zgadzają się z kartami i zmieniają się po edycji pliku JSON.

Podpowiedź

Trzy computed w serwisie oparte na _lista(), każdy z pętlą for…of.

ZAD 4Strona „O klubie”★★☆

Dodaj do menu drugą pozycję „O klubie” z osobną stroną: opis klubu, opiekun, sala spotkań i liczba wydarzeń w tym semestrze pobrana z serwisu.

Kryterium sukcesu: menu podświetla aktywną stronę; liczba wydarzeń na stronie „O klubie” zgadza się z listą, także po wejściu wprost na adres tej strony.

Podpowiedź

Nowy komponent, nowa trasa, nowy link. Serwis wstrzykujesz i czytasz lista().length.

ZAD 5Poprzednie i następne★★★

Dodaj na stronie szczegółów przyciski „← poprzednie” i „następne →” (lekcja 35), które przechodzą do sąsiednich wydarzeń w kolejności z listy, a nie po numerze id.

Kryterium sukcesu: przyciski działają także wtedy, gdy numery id nie idą po kolei (sprawdź, zmieniając id w JSON-ie na 1, 5, 9…); na końcach listy są wyłączone.

Podpowiedź

W serwisie metoda zwracająca pozycję wydarzenia w tablicy: findIndex(w => w.id === id). Id sąsiada to lista()[pozycja + 1].id.

ZAD 6Sortowanie w adresie★★★

Nad listą dodaj przyciski „wg daty” i „wg wolnych miejsc”, które ustawiają ?sort=data albo ?sort=wolne. Lista sortuje się w computed; domyślnie — wg daty.

Kryterium sukcesu: adres z ?sort=wolne otwarty w nowej karcie pokazuje od razu właściwą kolejność; karty się nie zmieniają, zmienia się tylko kolejność.

Podpowiedź

Kopia przed sortowaniem: [...serwis.lista()].sort(…). Daty „RRRR-MM-DD” porównasz jak teksty: a.data.localeCompare(b.data).

PODSUMOWANIE

Co trzeba zapamiętać

  • Projekt planujesz od danych: model, plik z danymi, serwis — dopiero potem strony.
  • Serwis jest jedynym źródłem danych; strony pytają go o dane, klocki dostają je przez input.
  • Szkielet z menu i <router-outlet /> uruchamia wczytywanie raz dla całej aplikacji.
  • Strona z parametrem obsługuje trzy stany: dane są, dane się wczytują, danych nie ma.
  • Każdy krok kończy się działającym programem — wersje tymczasowe plików są w porządku.

Sprawdź się: narysuj schemat aplikacji: które komponenty korzystają z serwisu, które dostają dane przez input, a które czytają parametr z adresu?

Dokumentacja: angular.dev — Build your first Angular app.

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