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.
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
:idi 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).
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/:idz listą zapisanych osób i komunikatem dla nieistniejącego numeru; - menu u góry i powrót ze szczegółów do listy.
| Plik | Rola | Krok |
|---|---|---|
wydarzenie.ts | model: interfejs Wydarzenie | 1 |
public/dane/projekt.json | dane „z serwera” | 1 |
wydarzenia-serwis.ts | pobiera i przechowuje wydarzenia; jedyne źródło danych | 2 |
demo.ts, demo.html | szkielet: menu i <router-outlet />; uruchamia wczytywanie | 2 |
trasy.ts | adresy stron | 2, 4 |
lista.ts, lista.html | strona z listą wydarzeń | 2, 3 |
karta.ts, karta.html | klocek: jedna karta wydarzenia | 3 |
szczegoly.ts, szczegoly.html | strona szczegółów z parametrem id | 4 |
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.
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ć.
export interface Wydarzenie {
id: number;
tytul: string;
kategoria: string;
data: string; // RRRR-MM-DD
sala: string;
limit: number;
zapisani: string[];
}
[
{ "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?
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.
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);
}
}
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();
}
}
<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>
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' },
];
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);
}
<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>
}
█ 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.
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.
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)
);
}
<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>
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);
}
<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>
}
[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”?
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.
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;
}
<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>
}
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' },
];
← 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?
Zestawienie elementów
| Element | Gdzie w projekcie | Lekcja |
|---|---|---|
interface z export | model Wydarzenie | 30 |
@Service(), inject, asReadonly() | WydarzeniaSerwis | 33 |
HttpClient.get, subscribe({ next, error }) | serwis — wczytaj() | 36 |
Routes, redirectTo, **, routerLinkActive | trasy.ts, szkielet | 34 |
:id, toSignal(paramMap) | strona szczegółów | 35 |
input.required, computed | karta | 29–30, 11 |
date:'EEEE, d MMMM' | karta i szczegóły | 16 |
[style.width.%], [class.bg-danger] | pasek zapełnienia | 15 |
@if … @else if … @else, @for … @empty | lista i szczegóły | 12–13 |
Zanim utkniesz
| Sytuacja | Problem |
|---|---|
| Dane wpisane na sztywno w komponencie listy | Lista 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 wydarzenia | Dział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/3 | Szczegóły na zawsze pokazują „nie ma wydarzenia”, bo nikt nie pobrał danych. Wczytuj w szkielecie. |
Brak gałęzi ladowanie() w szczegółach | Po 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
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.
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.
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.
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.
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.
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).
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.