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,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 });
},
}));
}