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
continuous-integration/drone/pr Build is passing
This commit is contained in:
@@ -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 });
|
||||
},
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user