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
@@ -0,0 +1,10 @@
export type TaskPriority = 'low' | 'medium' | 'high';
export type TaskFilter = 'all' | 'active' | 'completed';
export interface Task {
id: string;
title: string;
completed: boolean;
priority: TaskPriority;
updatedAt: string;
}
@@ -0,0 +1,82 @@
import { Injectable } from '@angular/core';
import { Observable, delay, of, throwError } from 'rxjs';
import { Task, TaskPriority } from './task.model';
const NETWORK_DELAY_MS = 250;
@Injectable({ providedIn: 'root' })
export class TasksApiService {
private tasks: Task[] = [
{
id: 'task-1',
title: 'Review signal store conventions',
completed: true,
priority: 'high',
updatedAt: '2026-03-08T08:00:00.000Z',
},
{
id: 'task-2',
title: 'Document persistence boundaries',
completed: false,
priority: 'medium',
updatedAt: '2026-03-08T08:02:00.000Z',
},
{
id: 'task-3',
title: 'Prepare lazy-loaded feature shell',
completed: false,
priority: 'low',
updatedAt: '2026-03-08T08:05:00.000Z',
},
];
getTasks(): Observable<Task[]> {
return of(this.tasks).pipe(delay(NETWORK_DELAY_MS));
}
createTask(title: string, priority: TaskPriority): Observable<Task> {
const trimmedTitle = title.trim();
if (!trimmedTitle) {
return throwError(() => new Error('Task title cannot be empty.'));
}
const task: Task = {
id: crypto.randomUUID(),
title: trimmedTitle,
completed: false,
priority,
updatedAt: new Date().toISOString(),
};
this.tasks = [task, ...this.tasks];
return of(task).pipe(delay(NETWORK_DELAY_MS));
}
toggleTask(taskId: string): Observable<Task> {
const task = this.tasks.find((item) => item.id === taskId);
if (!task) {
return throwError(() => new Error('Task not found.'));
}
const updatedTask: Task = {
...task,
completed: !task.completed,
updatedAt: new Date().toISOString(),
};
this.tasks = this.tasks.map((item) => (item.id === taskId ? updatedTask : item));
return of(updatedTask).pipe(delay(NETWORK_DELAY_MS));
}
archiveCompleted(): Observable<string[]> {
const archivedIds = this.tasks.filter((task) => task.completed).map((task) => task.id);
this.tasks = this.tasks.filter((task) => !task.completed);
return of(archivedIds).pipe(delay(NETWORK_DELAY_MS));
}
}
@@ -0,0 +1,221 @@
import { computed, inject } from '@angular/core';
import {
addEntity,
removeEntities,
setAllEntities,
updateEntity,
withEntities,
} from '@ngrx/signals/entities';
import {
signalStore,
withComputed,
withHooks,
withMethods,
withState,
} from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import {
setError,
setLoaded,
setLoading,
updateState,
withCallState,
withDevtools,
withLocalStorage,
withStorageSync,
} from '@angular-architects/ngrx-toolkit';
import { EMPTY, pipe } from 'rxjs';
import { catchError, switchMap, tap } from 'rxjs/operators';
import { Task, TaskFilter, TaskPriority } from './task.model';
import { TasksApiService } from './tasks-api.service';
interface TasksState {
filter: TaskFilter;
draftPriority: TaskPriority;
searchTerm: string;
lastSyncedAt: string | null;
}
const initialState: TasksState = {
filter: 'all',
draftPriority: 'medium',
searchTerm: '',
lastSyncedAt: null,
};
function matchesFilter(task: Task, filter: TaskFilter): boolean {
switch (filter) {
case 'active':
return !task.completed;
case 'completed':
return task.completed;
default:
return true;
}
}
export const TasksStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withEntities<Task>(),
withCallState(),
withDevtools('tasks-store'),
withStorageSync(
{
key: 'ngrx-playground.tasks',
select: (state) => ({
ids: state.ids,
entityMap: state.entityMap,
filter: state.filter,
draftPriority: state.draftPriority,
searchTerm: state.searchTerm,
lastSyncedAt: state.lastSyncedAt,
}),
},
withLocalStorage(),
),
withComputed((store) => ({
filteredTasks: computed(() => {
const normalizedSearch = store.searchTerm().trim().toLowerCase();
return store.entities().filter((task) => {
const matchesText = normalizedSearch.length === 0 || task.title.toLowerCase().includes(normalizedSearch);
return matchesFilter(task, store.filter()) && matchesText;
});
}),
totalCount: computed(() => store.entities().length),
activeCount: computed(() => store.entities().filter((task) => !task.completed).length),
completedCount: computed(() => store.entities().filter((task) => task.completed).length),
hasCompletedTasks: computed(() => store.entities().some((task) => task.completed)),
emptyStateMessage: computed(() => {
if (store.loading()) {
return 'Loading tasks...';
}
if (store.error()) {
return 'The task list could not be refreshed.';
}
if (store.entities().length === 0) {
return 'No tasks saved yet. Create the first one.';
}
return 'No tasks match the current filters.';
}),
lastSyncedLabel: computed(() => {
const value = store.lastSyncedAt();
return value ? new Date(value).toLocaleTimeString('en-US') : 'Never';
}),
})),
methodsStore(),
withHooks({
onInit(store) {
if (store.entities().length === 0) {
store.loadTasks();
}
},
}),
);
function methodsStore() {
return withMethods((store, api = inject(TasksApiService)) => ({
loadTasks: rxMethod<void>(
pipe(
tap(() => updateState(store, 'tasks load started', setLoading())),
switchMap(() =>
api.getTasks().pipe(
tap((tasks) =>
updateState(
store,
'tasks load succeeded',
setAllEntities(tasks),
{ lastSyncedAt: new Date().toISOString() },
setLoaded(),
),
),
catchError((error: unknown) => {
updateState(store, 'tasks load failed', setError(error));
return EMPTY;
}),
),
),
),
),
createTask: rxMethod<{ title: string; priority: TaskPriority }>(
pipe(
tap(() => updateState(store, 'task create started', setLoading())),
switchMap(({ title, priority }) =>
api.createTask(title, priority).pipe(
tap((task) =>
updateState(store, 'task create succeeded', addEntity(task), { lastSyncedAt: task.updatedAt }, setLoaded()),
),
catchError((error: unknown) => {
updateState(store, 'task create failed', setError(error));
return EMPTY;
}),
),
),
),
),
toggleTask: rxMethod<string>(
pipe(
tap(() => updateState(store, 'task toggle started', setLoading())),
switchMap((taskId) =>
api.toggleTask(taskId).pipe(
tap((task) =>
updateState(
store,
'task toggle succeeded',
updateEntity({ id: task.id, changes: task }),
{ lastSyncedAt: task.updatedAt },
setLoaded(),
),
),
catchError((error: unknown) => {
updateState(store, 'task toggle failed', setError(error));
return EMPTY;
}),
),
),
),
),
archiveCompleted: rxMethod<void>(
pipe(
tap(() => updateState(store, 'completed tasks archive started', setLoading())),
switchMap(() =>
api.archiveCompleted().pipe(
tap((archivedIds) =>
updateState(
store,
'completed tasks archive succeeded',
removeEntities(archivedIds),
{ lastSyncedAt: new Date().toISOString() },
setLoaded(),
),
),
catchError((error: unknown) => {
updateState(store, 'completed tasks archive failed', setError(error));
return EMPTY;
}),
),
),
),
),
setFilter(filter: TaskFilter): void {
updateState(store, 'task filter changed', { filter });
},
setDraftPriority(priority: TaskPriority): void {
updateState(store, 'task priority draft changed', { draftPriority: priority });
},
setSearchTerm(searchTerm: string): void {
updateState(store, 'task search changed', { searchTerm });
},
}));
}