feat: Implement tasks feature using NGRX signals and remove the old counter store, alongside general project configuration and skill documentation updates.
continuous-integration/drone/pr Build is passing

This commit is contained in:
Dennis Hundertmark
2026-03-08 09:50:17 +01:00
parent 2184971175
commit 9d13cc652a
47 changed files with 15272 additions and 14144 deletions
@@ -14,55 +14,55 @@
Encapsulate HTTP logic in services:
```typescript
import { Injectable, inject, signal, computed } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { httpResource } from "@angular/common/http";
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { httpResource } from '@angular/common/http';
export interface User {
id: string;
name: string;
email: string;
id: string;
name: string;
email: string;
}
@Injectable({ providedIn: "root" })
@Injectable({ providedIn: 'root' })
export class User {
private http = inject(HttpClient);
private baseUrl = "/api/users";
private http = inject(HttpClient);
private baseUrl = '/api/users';
// Current user ID for reactive fetching
private currentUserId = signal<string | null>(null);
// Current user ID for reactive fetching
private currentUserId = signal<string | null>(null);
// Reactive resource that updates when currentUserId changes
currentUser = httpResource<User>(() => {
const id = this.currentUserId();
return id ? `${this.baseUrl}/${id}` : undefined;
});
// Reactive resource that updates when currentUserId changes
currentUser = httpResource<User>(() => {
const id = this.currentUserId();
return id ? `${this.baseUrl}/${id}` : undefined;
});
// Set current user to fetch
selectUser(id: string) {
this.currentUserId.set(id);
}
// Set current user to fetch
selectUser(id: string) {
this.currentUserId.set(id);
}
// CRUD operations
getAll() {
return this.http.get<User[]>(this.baseUrl);
}
// CRUD operations
getAll() {
return this.http.get<User[]>(this.baseUrl);
}
getById(id: string) {
return this.http.get<User>(`${this.baseUrl}/${id}`);
}
getById(id: string) {
return this.http.get<User>(`${this.baseUrl}/${id}`);
}
create(user: Omit<User, "id">) {
return this.http.post<User>(this.baseUrl, user);
}
create(user: Omit<User, 'id'>) {
return this.http.post<User>(this.baseUrl, user);
}
update(id: string, user: Partial<User>) {
return this.http.patch<User>(`${this.baseUrl}/${id}`, user);
}
update(id: string, user: Partial<User>) {
return this.http.patch<User>(`${this.baseUrl}/${id}`, user);
}
delete(id: string) {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
delete(id: string) {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
}
```
@@ -71,67 +71,67 @@ export class User {
### Simple In-Memory Cache
```typescript
@Injectable({ providedIn: "root" })
@Injectable({ providedIn: 'root' })
export class CachedUser {
private http = inject(HttpClient);
private cache = new Map<string, { data: User; timestamp: number }>();
private cacheDuration = 5 * 60 * 1000; // 5 minutes
private http = inject(HttpClient);
private cache = new Map<string, { data: User; timestamp: number }>();
private cacheDuration = 5 * 60 * 1000; // 5 minutes
getUser(id: string): Observable<User> {
const cached = this.cache.get(id);
getUser(id: string): Observable<User> {
const cached = this.cache.get(id);
if (cached && Date.now() - cached.timestamp < this.cacheDuration) {
return of(cached.data);
if (cached && Date.now() - cached.timestamp < this.cacheDuration) {
return of(cached.data);
}
return this.http.get<User>(`/api/users/${id}`).pipe(
tap((user) => {
this.cache.set(id, { data: user, timestamp: Date.now() });
}),
);
}
return this.http.get<User>(`/api/users/${id}`).pipe(
tap((user) => {
this.cache.set(id, { data: user, timestamp: Date.now() });
}),
);
}
invalidateCache(id?: string) {
if (id) {
this.cache.delete(id);
} else {
this.cache.clear();
invalidateCache(id?: string) {
if (id) {
this.cache.delete(id);
} else {
this.cache.clear();
}
}
}
}
```
### Signal-Based Cache
```typescript
@Injectable({ providedIn: "root" })
@Injectable({ providedIn: 'root' })
export class UserCache {
private http = inject(HttpClient);
private http = inject(HttpClient);
// Cache as signal
private usersCache = signal<Map<string, User>>(new Map());
// Cache as signal
private usersCache = signal<Map<string, User>>(new Map());
// Computed for easy access
users = computed(() => Array.from(this.usersCache().values()));
// Computed for easy access
users = computed(() => Array.from(this.usersCache().values()));
getUser(id: string): User | undefined {
return this.usersCache().get(id);
}
getUser(id: string): User | undefined {
return this.usersCache().get(id);
}
async fetchUser(id: string): Promise<User> {
const cached = this.getUser(id);
if (cached) return cached;
async fetchUser(id: string): Promise<User> {
const cached = this.getUser(id);
if (cached) return cached;
const user = await firstValueFrom(this.http.get<User>(`/api/users/${id}`));
const user = await firstValueFrom(this.http.get<User>(`/api/users/${id}`));
this.usersCache.update((cache) => {
const newCache = new Map(cache);
newCache.set(id, user);
return newCache;
});
this.usersCache.update((cache) => {
const newCache = new Map(cache);
newCache.set(id, user);
return newCache;
});
return user;
}
return user;
}
}
```
@@ -141,58 +141,61 @@ export class UserCache {
```typescript
interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
data: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
@Component({
template: `
@if (usersResource.isLoading()) {
<app-spinner />
} @else if (usersResource.hasValue()) {
<ul>
@for (user of usersResource.value().data; track user.id) {
<li>{{ user.name }}</li>
template: `
@if (usersResource.isLoading()) {
<app-spinner />
} @else if (usersResource.hasValue()) {
<ul>
@for (user of usersResource.value().data; track user.id) {
<li>{{ user.name }}</li>
}
</ul>
<div class="pagination">
<button
[disabled]="page() === 1"
(click)="prevPage()">
Previous
</button>
<span>Page {{ page() }} of {{ usersResource.value().totalPages }}</span>
<button
[disabled]="page() >= usersResource.value().totalPages"
(click)="nextPage()">
Next
</button>
</div>
}
</ul>
<div class="pagination">
<button (click)="prevPage()" [disabled]="page() === 1">Previous</button>
<span>Page {{ page() }} of {{ usersResource.value().totalPages }}</span>
<button
(click)="nextPage()"
[disabled]="page() >= usersResource.value().totalPages"
>
Next
</button>
</div>
}
`,
`,
})
export class UsersList {
page = signal(1);
pageSize = signal(10);
page = signal(1);
pageSize = signal(10);
usersResource = httpResource<PaginatedResponse<User>>(() => ({
url: "/api/users",
params: {
page: this.page().toString(),
pageSize: this.pageSize().toString(),
},
}));
usersResource = httpResource<PaginatedResponse<User>>(() => ({
url: '/api/users',
params: {
page: this.page().toString(),
pageSize: this.pageSize().toString(),
},
}));
nextPage() {
this.page.update((p) => p + 1);
}
nextPage() {
this.page.update((p) => p + 1);
}
prevPage() {
this.page.update((p) => Math.max(1, p - 1));
}
prevPage() {
this.page.update((p) => Math.max(1, p - 1));
}
}
```
@@ -200,58 +203,58 @@ export class UsersList {
```typescript
@Component({
template: `
<ul>
@for (user of allUsers(); track user.id) {
<li>{{ user.name }}</li>
}
</ul>
template: `
<ul>
@for (user of allUsers(); track user.id) {
<li>{{ user.name }}</li>
}
</ul>
@if (isLoading()) {
<app-spinner />
}
@if (isLoading()) {
<app-spinner />
}
@if (hasMore()) {
<button (click)="loadMore()">Load More</button>
}
`,
@if (hasMore()) {
<button (click)="loadMore()">Load More</button>
}
`,
})
export class InfiniteUsers {
private http = inject(HttpClient);
private http = inject(HttpClient);
private page = signal(1);
private users = signal<User[]>([]);
private totalPages = signal(1);
private page = signal(1);
private users = signal<User[]>([]);
private totalPages = signal(1);
allUsers = this.users.asReadonly();
isLoading = signal(false);
hasMore = computed(() => this.page() < this.totalPages());
allUsers = this.users.asReadonly();
isLoading = signal(false);
hasMore = computed(() => this.page() < this.totalPages());
constructor() {
this.loadPage(1);
}
loadMore() {
this.loadPage(this.page() + 1);
}
private async loadPage(page: number) {
this.isLoading.set(true);
try {
const response = await firstValueFrom(
this.http.get<PaginatedResponse<User>>("/api/users", {
params: { page: page.toString(), pageSize: "20" },
}),
);
this.users.update((users) => [...users, ...response.data]);
this.page.set(page);
this.totalPages.set(response.totalPages);
} finally {
this.isLoading.set(false);
constructor() {
this.loadPage(1);
}
loadMore() {
this.loadPage(this.page() + 1);
}
private async loadPage(page: number) {
this.isLoading.set(true);
try {
const response = await firstValueFrom(
this.http.get<PaginatedResponse<User>>('/api/users', {
params: { page: page.toString(), pageSize: '20' },
}),
);
this.users.update((users) => [...users, ...response.data]);
this.page.set(page);
this.totalPages.set(response.totalPages);
} finally {
this.isLoading.set(false);
}
}
}
}
```
@@ -261,42 +264,44 @@ export class InfiniteUsers {
```typescript
@Component({
template: `
<input type="file" (change)="onFileSelected($event)" />
template: `
<input
type="file"
(change)="onFileSelected($event)" />
@if (uploadProgress() !== null) {
<progress [value]="uploadProgress()" max="100"></progress>
}
`,
@if (uploadProgress() !== null) {
<progress
max="100"
[value]="uploadProgress()"></progress>
}
`,
})
export class FileUpload {
private http = inject(HttpClient);
private http = inject(HttpClient);
uploadProgress = signal<number | null>(null);
uploadProgress = signal<number | null>(null);
onFileSelected(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
onFileSelected(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
const formData = new FormData();
formData.append("file", file);
const formData = new FormData();
formData.append('file', file);
this.http
.post("/api/upload", formData, {
reportProgress: true,
observe: "events",
})
.subscribe((event) => {
if (event.type === HttpEventType.UploadProgress && event.total) {
this.uploadProgress.set(
Math.round((100 * event.loaded) / event.total),
);
} else if (event.type === HttpEventType.Response) {
this.uploadProgress.set(null);
console.log("Upload complete:", event.body);
}
});
}
this.http
.post('/api/upload', formData, {
reportProgress: true,
observe: 'events',
})
.subscribe((event) => {
if (event.type === HttpEventType.UploadProgress && event.total) {
this.uploadProgress.set(Math.round((100 * event.loaded) / event.total));
} else if (event.type === HttpEventType.Response) {
this.uploadProgress.set(null);
console.log('Upload complete:', event.body);
}
});
}
}
```
@@ -321,13 +326,13 @@ uploadFiles(files: FileList) {
```typescript
// resource() automatically handles cancellation via abortSignal
searchResource = resource({
params: () => ({ q: this.query() }),
loader: async ({ params, abortSignal }) => {
const response = await fetch(`/api/search?q=${params.q}`, {
signal: abortSignal, // Cancels if params change
});
return response.json();
},
params: () => ({ q: this.query() }),
loader: async ({ params, abortSignal }) => {
const response = await fetch(`/api/search?q=${params.q}`, {
signal: abortSignal, // Cancels if params change
});
return response.json();
},
});
```
@@ -383,64 +388,64 @@ export class SearchDebounced {
### Testing httpResource
```typescript
describe("UserCmpt", () => {
let component: UserCmpt;
let httpMock: HttpTestingController;
describe('UserCmpt', () => {
let component: UserCmpt;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [UserCmpt],
providers: [provideHttpClientTesting()],
beforeEach(() => {
TestBed.configureTestingModule({
imports: [UserCmpt],
providers: [provideHttpClientTesting()],
});
component = TestBed.createComponent(UserCmpt).componentInstance;
httpMock = TestBed.inject(HttpTestingController);
});
component = TestBed.createComponent(UserCmpt).componentInstance;
httpMock = TestBed.inject(HttpTestingController);
});
it('should load user', () => {
component.userId.set('123');
it("should load user", () => {
component.userId.set("123");
const req = httpMock.expectOne('/api/users/123');
req.flush({ id: '123', name: 'Test User' });
const req = httpMock.expectOne("/api/users/123");
req.flush({ id: "123", name: "Test User" });
expect(component.userResource.value()?.name).toBe('Test User');
});
expect(component.userResource.value()?.name).toBe("Test User");
});
afterEach(() => {
httpMock.verify();
});
afterEach(() => {
httpMock.verify();
});
});
```
### Testing Services
```typescript
describe("User", () => {
let service: User;
let httpMock: HttpTestingController;
describe('User', () => {
let service: User;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [User, provideHttpClient(), provideHttpClientTesting()],
beforeEach(() => {
TestBed.configureTestingModule({
providers: [User, provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(User);
httpMock = TestBed.inject(HttpTestingController);
});
service = TestBed.inject(User);
httpMock = TestBed.inject(HttpTestingController);
});
it('should create user', () => {
const newUser = { name: 'Test', email: 'test@example.com' };
it("should create user", () => {
const newUser = { name: "Test", email: "test@example.com" };
service.create(newUser).subscribe((user) => {
expect(user.id).toBeDefined();
expect(user.name).toBe('Test');
});
service.create(newUser).subscribe((user) => {
expect(user.id).toBeDefined();
expect(user.name).toBe("Test");
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(newUser);
req.flush({ id: '1', ...newUser });
});
const req = httpMock.expectOne("/api/users");
expect(req.request.method).toBe("POST");
expect(req.request.body).toEqual(newUser);
req.flush({ id: "1", ...newUser });
});
});
```