feat: add Angular application source code with task management feature

This commit is contained in:
Andres Fabian Patiño Bermudez 2026-05-14 21:38:35 -05:00
parent fede1a6c6f
commit 7d9c4acc7a
92 changed files with 4448 additions and 0 deletions

33
src/app/app.config.ts Normal file
View File

@ -0,0 +1,33 @@
import {
ApplicationConfig,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import {
provideRouter,
withComponentInputBinding,
withViewTransitions,
} from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { routes } from './app.routes';
import { errorInterceptor, loadingInterceptor, apiResponseInterceptor } from '@core/index';
import { TASK_DATA_SOURCE } from './features/tasks/data-access/data-sources/task-data-source.token';
import { HttpTaskDataSource } from './features/tasks/data-access/data-sources/http-task.data-source';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes, withComponentInputBinding(), withViewTransitions()),
provideHttpClient(withInterceptors([
apiResponseInterceptor,
errorInterceptor,
loadingInterceptor,
])),
provideAnimationsAsync(),
{
provide: TASK_DATA_SOURCE,
useClass: HttpTaskDataSource,
},
],
};

5
src/app/app.css Normal file
View File

@ -0,0 +1,5 @@
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}

4
src/app/app.html Normal file
View File

@ -0,0 +1,4 @@
<main id="main" class="app">
<router-outlet />
<emi-toast />
</main>

19
src/app/app.routes.ts Normal file
View File

@ -0,0 +1,19 @@
import { Routes } from '@angular/router';
import { NotFound } from '@shared/index';
export const routes: Routes = [
{
path: '',
redirectTo: 'tasks',
pathMatch: 'full',
},
{
path: 'tasks',
loadChildren: () =>
import('./features/tasks/tasks.routes').then(m => m.TASK_ROUTES),
},
{
path: '**',
component: NotFound,
},
];

13
src/app/app.ts Normal file
View File

@ -0,0 +1,13 @@
import { Component, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { Toast } from '@shared/ui/toast/toast';
@Component({
selector: 'emi-root',
imports: [RouterOutlet, Toast],
templateUrl: './app.html',
styleUrl: './app.css'
})
export class App {
protected readonly title = signal('task-challenge');
}

View File

@ -0,0 +1,9 @@
import { CanDeactivateFn } from '@angular/router';
export interface CanComponentDeactivate {
canDeactivate: () => boolean;
}
export const unsavedChangesGuard: CanDeactivateFn<CanComponentDeactivate> = (component) => {
return component.canDeactivate ? component.canDeactivate() : true;
};

14
src/app/core/index.ts Normal file
View File

@ -0,0 +1,14 @@
export { provideCore } from './providers';
export { errorInterceptor } from './interceptors/error-interceptor';
export { loadingInterceptor } from './interceptors/loading-interceptor';
export { apiResponseInterceptor } from './interceptors/api-response-interceptor';
export { Notification } from './services/notification';
export { Loading } from './services/loading';
export { unsavedChangesGuard } from './guards/unsaved-changes-guard';
export type { CanComponentDeactivate } from './guards/unsaved-changes-guard';
export type { ApiError } from './models/api-error.model';
export type { Pagination, PaginatedResponse, PaginationParams } from './models/pagination.model';

View File

@ -0,0 +1,22 @@
import { HttpInterceptorFn, HttpResponse } from '@angular/common/http';
import { map } from 'rxjs';
interface ApiResponse<T> {
success: boolean;
data: T;
}
export const apiResponseInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
map(event => {
if (event instanceof HttpResponse && event.body) {
const body = event.body as ApiResponse<unknown>;
if ('success' in body && 'data' in body) {
return event.clone({ body: body.data });
}
}
return event;
})
);
};

View File

@ -0,0 +1,34 @@
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
import { Notification } from '../services/notification';
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const notification = inject(Notification);
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
let message = 'An unexpected error occurred';
if (error.status === 0) {
message = 'Unable to connect to the server. Please check your connection.';
} else if (error.status === 404) {
message = 'The requested resource was not found.';
} else if (error.status === 400) {
message = error.error?.error || error.error?.message || 'Invalid request. Please check your input.';
} else if (error.status === 401) {
message = 'You are not authorized to perform this action.';
} else if (error.status === 403) {
message = 'You do not have permission to perform this action.';
} else if (error.status === 500) {
message = 'A server error occurred. Please try again later.';
} else if (error.error?.error || error.error?.message) {
message = error.error.error || error.error.message;
}
notification.error(message);
return throwError(() => error);
})
);
};

View File

@ -0,0 +1,5 @@
import { HttpInterceptorFn } from '@angular/common/http';
export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
return next(req);
};

View File

@ -0,0 +1,6 @@
export interface ApiError {
status: number;
message: string;
timestamp: string;
path?: string;
}

View File

@ -0,0 +1,16 @@
export interface Pagination {
page: number;
pageSize: number;
total: number;
totalPages: number;
}
export interface PaginatedResponse<T> {
data: T[];
pagination: Pagination;
}
export interface PaginationParams {
page?: number;
pageSize?: number;
}

View File

@ -0,0 +1,5 @@
import { type EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';
export function provideCore(): EnvironmentProviders {
return makeEnvironmentProviders([]);
}

View File

@ -0,0 +1,6 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class Loading {}

View File

@ -0,0 +1,45 @@
import { Injectable } from '@angular/core';
import { signal } from '@angular/core';
export interface NotificationMessage {
id: number;
type: 'success' | 'error' | 'warning' | 'info';
message: string;
}
@Injectable({
providedIn: 'root',
})
export class Notification {
private readonly messages = signal<NotificationMessage[]>([]);
private nextId = 0;
readonly notifications = this.messages.asReadonly();
show(type: NotificationMessage['type'], message: string): void {
const id = this.nextId++;
this.messages.update(msgs => [...msgs, { id, type, message }]);
setTimeout(() => this.dismiss(id), 5000);
}
success(message: string): void {
this.show('success', message);
}
error(message: string): void {
this.show('error', message);
}
warning(message: string): void {
this.show('warning', message);
}
info(message: string): void {
this.show('info', message);
}
dismiss(id: number): void {
this.messages.update(msgs => msgs.filter(m => m.id !== id));
}
}

View File

View File

@ -0,0 +1,55 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, map } from 'rxjs';
import { TaskDataSource } from './task-data-source.interface';
import { Task, CreateTaskDto, UpdateTaskDto, TaskState } from '../models/task.model';
import { PaginatedResponse, PaginationParams } from '@core/index';
import { environment } from '@environments/environment';
@Injectable()
export class HttpTaskDataSource implements TaskDataSource {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}/tasks`;
getAll(_params?: PaginationParams): Observable<PaginatedResponse<Task>> {
return this.http.get<Task[]>(this.baseUrl).pipe(
map(tasks => ({
data: tasks,
pagination: {
page: 1,
pageSize: tasks.length,
total: tasks.length,
totalPages: 1,
},
}))
);
}
getById(id: string): Observable<Task> {
return this.http.get<Task>(`${this.baseUrl}/${id}`);
}
create(dto: CreateTaskDto): Observable<Task> {
return this.http.post<Task>(this.baseUrl, dto);
}
update(id: string, dto: UpdateTaskDto): Observable<Task> {
return this.http.put<Task>(`${this.baseUrl}/${id}`, dto);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
transition(id: string, state: TaskState): Observable<Task> {
return this.http.patch<Task>(`${this.baseUrl}/${id}/transition`, { state });
}
addNote(taskId: string, note: string): Observable<Task> {
return this.http.post<Task>(`${this.baseUrl}/${taskId}/notes`, { note });
}
deleteNote(taskId: string, noteIndex: number): Observable<Task> {
return this.http.delete<Task>(`${this.baseUrl}/${taskId}/notes/${noteIndex}`);
}
}

View File

@ -0,0 +1,14 @@
import { Observable } from 'rxjs';
import { Task, CreateTaskDto, UpdateTaskDto, TaskState } from '../models/task.model';
import { PaginatedResponse, PaginationParams } from '@core/index';
export interface TaskDataSource {
getAll(params?: PaginationParams): Observable<PaginatedResponse<Task>>;
getById(id: string): Observable<Task>;
create(dto: CreateTaskDto): Observable<Task>;
update(id: string, dto: UpdateTaskDto): Observable<Task>;
delete(id: string): Observable<void>;
transition(id: string, state: TaskState): Observable<Task>;
addNote(taskId: string, note: string): Observable<Task>;
deleteNote(taskId: string, noteIndex: number): Observable<Task>;
}

View File

@ -0,0 +1,4 @@
import { InjectionToken } from '@angular/core';
import { TaskDataSource } from './task-data-source.interface';
export const TASK_DATA_SOURCE = new InjectionToken<TaskDataSource>('TaskDataSource');

View File

@ -0,0 +1,14 @@
export { TaskService } from './services/task';
export { TaskStore } from './store/task-store';
export { taskResolver } from './resolvers/task-resolver';
export type { Task, TaskState, StateHistoryEntry, CreateTaskDto, UpdateTaskDto } from './models/task.model';
export { createTask } from './models/task.model';
export type { TaskFilter, TaskSort, TaskListState } from './models/task-state.model';
export { defaultFilter, defaultSort } from './models/task-state.model';
export type { TaskDto } from './models/task-dto.model';
export type { TaskDataSource } from './data-sources/task-data-source.interface';
export { TASK_DATA_SOURCE } from './data-sources/task-data-source.token';
export { HttpTaskDataSource } from './data-sources/http-task.data-source';

View File

@ -0,0 +1,8 @@
export interface TaskDto {
id: string;
title: string;
description: string;
dueDate: string;
stateHistory: { state: string; date: string }[];
notes: string[];
}

View File

@ -0,0 +1,29 @@
import { Task, TaskState } from './task.model';
export interface TaskFilter {
state: TaskState | 'all';
search: string;
}
export interface TaskSort {
field: keyof Task;
direction: 'asc' | 'desc';
}
export interface TaskListState {
tasks: Task[];
filter: TaskFilter;
sort: TaskSort;
loading: boolean;
error: string | null;
}
export const defaultFilter: TaskFilter = {
state: 'all',
search: '',
};
export const defaultSort: TaskSort = {
field: 'dueDate',
direction: 'asc',
};

View File

@ -0,0 +1,43 @@
import { v4 as uuid } from 'uuid';
export type TaskState = 'new' | 'active' | 'resolved' | 'closed';
export interface StateHistoryEntry {
state: TaskState;
date: string;
}
export interface Task {
id: string;
title: string;
description: string;
dueDate: string;
stateHistory: StateHistoryEntry[];
notes: string[];
}
export interface CreateTaskDto {
title: string;
description?: string;
dueDate?: string;
initialNote?: string;
}
export interface UpdateTaskDto {
title?: string;
description?: string;
dueDate?: string;
state?: TaskState;
}
export function createTask(dto: CreateTaskDto): Task {
const now = new Date().toISOString().split('T')[0];
return {
id: uuid(),
title: dto.title,
description: dto.description ?? '',
dueDate: dto.dueDate ?? now,
stateHistory: [{ state: 'new', date: now }],
notes: [],
};
}

View File

@ -0,0 +1,24 @@
import { ResolveFn } from '@angular/router';
import { inject } from '@angular/core';
import { TaskStore } from '../store/task-store';
import { Task } from '../models/task.model';
import { firstValueFrom } from 'rxjs';
import { TaskService } from '../services/task';
export const taskResolver: ResolveFn<Task | null> = async (route) => {
const service = inject(TaskService);
const store = inject(TaskStore);
const id = route.paramMap.get('id');
if (!id) {
return null;
}
try {
const task = await firstValueFrom(service.getById(id));
store.setSelectedTask(task);
return task;
} catch {
return null;
}
};

View File

@ -0,0 +1,43 @@
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { TaskDataSource } from '../data-sources/task-data-source.interface';
import { TASK_DATA_SOURCE } from '../data-sources/task-data-source.token';
import { Task, CreateTaskDto, UpdateTaskDto, TaskState } from '../models/task.model';
import { PaginatedResponse, PaginationParams } from '@core/index';
@Injectable({ providedIn: 'root' })
export class TaskService {
private readonly dataSource = inject<TaskDataSource>(TASK_DATA_SOURCE);
getAll(params?: PaginationParams): Observable<PaginatedResponse<Task>> {
return this.dataSource.getAll(params);
}
getById(id: string): Observable<Task> {
return this.dataSource.getById(id);
}
create(dto: CreateTaskDto): Observable<Task> {
return this.dataSource.create(dto);
}
update(id: string, dto: UpdateTaskDto): Observable<Task> {
return this.dataSource.update(id, dto);
}
delete(id: string): Observable<void> {
return this.dataSource.delete(id);
}
transition(id: string, state: TaskState): Observable<Task> {
return this.dataSource.transition(id, state);
}
addNote(taskId: string, note: string): Observable<Task> {
return this.dataSource.addNote(taskId, note);
}
deleteNote(taskId: string, noteIndex: number): Observable<Task> {
return this.dataSource.deleteNote(taskId, noteIndex);
}
}

View File

@ -0,0 +1,260 @@
import { Injectable, inject, signal, computed } from '@angular/core';
import { TaskService } from '../services/task';
import { Task, CreateTaskDto, UpdateTaskDto, TaskState } from '../models/task.model';
import { TaskFilter, TaskSort, defaultFilter, defaultSort } from '../models/task-state.model';
import { PaginatedResponse, PaginationParams } from '@core/index';
import { Notification } from '@core/services/notification';
@Injectable({ providedIn: 'root' })
export class TaskStore {
private readonly taskService = inject(TaskService);
private readonly notification = inject(Notification);
private readonly tasksSignal = signal<Task[]>([]);
private readonly selectedTaskSignal = signal<Task | null>(null);
private readonly filterSignal = signal<TaskFilter>(defaultFilter);
private readonly sortSignal = signal<TaskSort>(defaultSort);
private readonly loadingSignal = signal(false);
private readonly errorSignal = signal<string | null>(null);
private readonly paginationSignal = signal<PaginatedResponse<Task>['pagination'] | null>(null);
private readonly transitioningStateSignal = signal<TaskState | null>(null);
readonly tasks = this.tasksSignal.asReadonly();
readonly selectedTask = this.selectedTaskSignal.asReadonly();
readonly filter = this.filterSignal.asReadonly();
readonly sort = this.sortSignal.asReadonly();
readonly loading = this.loadingSignal.asReadonly();
readonly error = this.errorSignal.asReadonly();
readonly pagination = this.paginationSignal.asReadonly();
readonly transitioningState = this.transitioningStateSignal.asReadonly();
readonly filteredTasks = computed(() => {
let result = this.tasksSignal();
const filter = this.filterSignal();
if (filter.state !== 'all') {
result = result.filter(t => {
const lastState = t.stateHistory[t.stateHistory.length - 1];
return lastState?.state === filter.state;
});
}
if (filter.search) {
const search = filter.search.toLowerCase();
result = result.filter(t =>
t.title.toLowerCase().includes(search) ||
t.description.toLowerCase().includes(search)
);
}
return result;
});
loadTasks(params?: PaginationParams): void {
this.loadingSignal.set(true);
this.errorSignal.set(null);
this.taskService.getAll(params).subscribe({
next: (response) => {
this.tasksSignal.set(response.data);
this.paginationSignal.set(response.pagination);
this.loadingSignal.set(false);
},
error: (error: Error) => {
this.errorSignal.set(error.message);
this.loadingSignal.set(false);
},
});
}
loadTask(id: string): void {
this.loadingSignal.set(true);
this.errorSignal.set(null);
this.taskService.getById(id).subscribe({
next: (task) => {
this.selectedTaskSignal.set(task);
this.loadingSignal.set(false);
},
error: (error: Error) => {
this.errorSignal.set(error.message);
this.loadingSignal.set(false);
},
});
}
setSelectedTask(task: Task | null): void {
this.selectedTaskSignal.set(task);
}
createTask(dto: CreateTaskDto): void {
this.loadingSignal.set(true);
this.errorSignal.set(null);
this.taskService.create(dto).subscribe({
next: (task) => {
if (dto.initialNote?.trim()) {
this.taskService.addNote(task.id, dto.initialNote).subscribe({
next: (updated) => {
this.tasksSignal.update(tasks => [...tasks, updated]);
this.loadingSignal.set(false);
},
error: () => {
this.tasksSignal.update(tasks => [...tasks, task]);
this.loadingSignal.set(false);
},
});
} else {
this.tasksSignal.update(tasks => [...tasks, task]);
this.loadingSignal.set(false);
}
},
error: (error: Error) => {
this.errorSignal.set(error.message);
this.loadingSignal.set(false);
},
});
}
updateTask(id: string, dto: UpdateTaskDto): void {
this.loadingSignal.set(true);
this.errorSignal.set(null);
this.taskService.update(id, dto).subscribe({
next: (updated) => {
this.tasksSignal.update(tasks =>
tasks.map(t => t.id === id ? updated : t)
);
if (this.selectedTaskSignal()?.id === id) {
this.selectedTaskSignal.set(updated);
}
this.loadingSignal.set(false);
},
error: (error: Error) => {
this.errorSignal.set(error.message);
this.loadingSignal.set(false);
},
});
}
deleteTask(id: string): void {
this.loadingSignal.set(true);
this.errorSignal.set(null);
this.taskService.delete(id).subscribe({
next: () => {
this.tasksSignal.update(tasks => tasks.filter(t => t.id !== id));
if (this.selectedTaskSignal()?.id === id) {
this.selectedTaskSignal.set(null);
}
this.loadingSignal.set(false);
},
error: (error: Error) => {
this.errorSignal.set(error.message);
this.loadingSignal.set(false);
},
});
}
transitionTask(id: string, state: TaskState): void {
const currentTask = this.tasksSignal().find(t => t.id === id);
if (!currentTask) return;
const currentState = currentTask.stateHistory[currentTask.stateHistory.length - 1]?.state;
if (currentState === state) return;
this.transitioningStateSignal.set(state);
const optimisticTask: Task = {
...currentTask,
stateHistory: [
...currentTask.stateHistory,
{ state, date: new Date().toISOString().split('T')[0] },
],
};
this.tasksSignal.update(tasks =>
tasks.map(t => t.id === id ? optimisticTask : t)
);
if (this.selectedTaskSignal()?.id === id) {
this.selectedTaskSignal.set(optimisticTask);
}
this.taskService.transition(id, state).subscribe({
next: (updated) => {
this.tasksSignal.update(tasks =>
tasks.map(t => t.id === id ? updated : t)
);
if (this.selectedTaskSignal()?.id === id) {
this.selectedTaskSignal.set(updated);
}
this.transitioningStateSignal.set(null);
this.notification.success(`Task moved to ${state}`);
},
error: () => {
this.tasksSignal.update(tasks =>
tasks.map(t => t.id === id ? currentTask : t)
);
if (this.selectedTaskSignal()?.id === id) {
this.selectedTaskSignal.set(currentTask);
}
this.transitioningStateSignal.set(null);
},
});
}
addNote(taskId: string, note: string): void {
this.taskService.addNote(taskId, note).subscribe({
next: (updated) => {
this.tasksSignal.update(tasks =>
tasks.map(t => t.id === taskId ? updated : t)
);
if (this.selectedTaskSignal()?.id === taskId) {
this.selectedTaskSignal.set(updated);
}
},
error: (error: Error) => {
this.errorSignal.set(error.message);
},
});
}
deleteNote(taskId: string, noteIndex: number): void {
this.taskService.deleteNote(taskId, noteIndex).subscribe({
next: (updated) => {
this.tasksSignal.update(tasks =>
tasks.map(t => t.id === taskId ? updated : t)
);
if (this.selectedTaskSignal()?.id === taskId) {
this.selectedTaskSignal.set(updated);
}
},
error: (error: Error) => {
this.errorSignal.set(error.message);
},
});
}
updateFilter(filter: Partial<TaskFilter>): void {
this.filterSignal.update(f => ({ ...f, ...filter }));
}
updateSort(sort: TaskSort): void {
this.sortSignal.set(sort);
}
clearError(): void {
this.errorSignal.set(null);
}
reset(): void {
this.tasksSignal.set([]);
this.selectedTaskSignal.set(null);
this.filterSignal.set(defaultFilter);
this.sortSignal.set(defaultSort);
this.loadingSignal.set(false);
this.errorSignal.set(null);
this.paginationSignal.set(null);
this.transitioningStateSignal.set(null);
}
}

View File

@ -0,0 +1,86 @@
.task-create-page {
padding: var(--space-6) var(--space-4);
max-width: 600px;
margin: 0 auto;
width: 100%;
}
.task-create-page__header {
margin-bottom: var(--space-8);
}
.task-create-page__title {
font-family: var(--font-display);
font-size: var(--fs-2xl);
font-weight: var(--fw-bold);
color: var(--text-primary);
margin: 0;
}
.task-create-page__error {
background-color: rgba(220, 38, 38, 0.1);
color: var(--color-danger);
padding: var(--space-4);
border-radius: var(--radius-md);
margin-bottom: var(--space-6);
font-size: var(--fs-sm);
}
.task-create-page__form {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.task-create-page__form-field {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.task-create-page__label {
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
color: var(--text-secondary);
}
.task-create-page__input,
.task-create-page__textarea {
padding: var(--space-3) var(--space-4);
font-size: var(--fs-base);
font-family: var(--font-body);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
background-color: var(--bg-surface);
color: var(--text-primary);
transition: border-color var(--transition-fast);
min-height: 44px;
width: 100%;
}
.task-create-page__textarea {
min-height: 120px;
resize: vertical;
}
.task-create-page__input:focus,
.task-create-page__textarea:focus {
outline: none;
border-color: var(--border-focus);
box-shadow: 0 0 0 3px rgba(230, 57, 70, 0.15);
}
.task-create-page__actions {
display: flex;
gap: var(--space-3);
justify-content: flex-end;
margin-top: var(--space-6);
padding-top: var(--space-6);
border-top: 1px solid var(--border-subtle);
}
@media (min-width: 768px) {
.task-create-page {
padding: var(--space-8);
}
}

View File

@ -0,0 +1,94 @@
<section class="task-create-page" aria-label="Create new task">
<header class="task-create-page__header">
<h1 class="task-create-page__title">Create Task</h1>
</header>
@if (error()) {
<div class="task-create-page__error" role="alert" aria-live="polite">
{{ error() }}
</div>
}
<form
class="task-create-page__form"
[formGroup]="form"
(ngSubmit)="onSubmit()"
aria-label="Task form"
>
<div class="task-create-page__form-field">
<label class="task-create-page__label" for="title">
Title <span aria-hidden="true">*</span>
<span class="sr-only">(required)</span>
</label>
<input
class="task-create-page__input"
id="title"
formControlName="title"
type="text"
placeholder="Enter task title"
aria-required="true"
aria-describedby="title-hint"
/>
<span id="title-hint" class="sr-only">Enter a descriptive title for the task</span>
</div>
<div class="task-create-page__form-field">
<label class="task-create-page__label" for="description">Description</label>
<textarea
class="task-create-page__textarea"
id="description"
formControlName="description"
placeholder="Enter task description"
aria-describedby="desc-hint"
></textarea>
<span id="desc-hint" class="sr-only">Optional: provide additional details about the task</span>
</div>
<div class="task-create-page__form-field">
<label class="task-create-page__label" for="dueDate">Due Date</label>
<input
class="task-create-page__input"
id="dueDate"
formControlName="dueDate"
type="date"
aria-describedby="date-hint"
/>
<span id="date-hint" class="sr-only">Select when this task should be completed</span>
</div>
<div class="task-create-page__form-field">
<label class="task-create-page__label" for="initialNote">
Initial Note <span aria-hidden="true">*</span>
<span class="sr-only">(required)</span>
</label>
<textarea
class="task-create-page__textarea"
id="initialNote"
formControlName="initialNote"
placeholder="Add an initial note for this task"
aria-required="true"
aria-describedby="note-hint"
></textarea>
<span id="note-hint" class="sr-only">Required: add at least one note to the task</span>
</div>
<div class="task-create-page__actions" role="group" aria-label="Form actions">
<emi-button
type="button"
variant="ghost"
(clicked)="onCancel()"
aria-label="Cancel and go back to task list"
>
Cancel
</emi-button>
<emi-button
type="submit"
variant="primary"
[disabled]="loading() || form.invalid"
aria-label="Create new task"
>
{{ loading() ? 'Creating...' : 'Create Task' }}
</emi-button>
</div>
</form>
</section>

View File

@ -0,0 +1,46 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { NonNullableFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { TaskStore } from '../../data-access/store/task-store';
import { CreateTaskDto } from '../../data-access/models/task.model';
import { trimmedRequiredValidator } from '@shared/index';
import { Button } from '@shared/index';
@Component({
selector: 'emi-task-create-page',
imports: [ReactiveFormsModule, Button],
templateUrl: './task-create-page.html',
styleUrl: './task-create-page.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TaskCreatePage {
private readonly store = inject(TaskStore);
private readonly router = inject(Router);
private readonly fb = inject(NonNullableFormBuilder);
readonly loading = this.store.loading;
readonly error = this.store.error;
readonly form = this.fb.group({
title: ['', [trimmedRequiredValidator, Validators.maxLength(100)]],
description: ['', Validators.maxLength(500)],
dueDate: this.fb.control<string>(new Date().toISOString().split('T')[0]),
initialNote: ['', [trimmedRequiredValidator, Validators.maxLength(500)]],
});
onSubmit(): void {
if (this.form.valid) {
const dto: CreateTaskDto = this.form.getRawValue();
this.store.createTask(dto);
this.router.navigate(['/tasks']);
}
}
onCancel(): void {
this.router.navigate(['/tasks']);
}
canDeactivate(): boolean {
return this.form.pristine;
}
}

View File

@ -0,0 +1,47 @@
<section class="task-detail">
@if (loading()) {
<p>Loading...</p>
}
@if (error()) {
<p class="error">{{ error() }}</p>
}
@if (task(); as task) {
<header class="task-detail__header">
<button (click)="onBack()">Back</button>
<h1>{{ task.title }}</h1>
<emi-task-state-badge [state]="currentState ?? 'new'" />
</header>
<div class="task-detail__info">
<p><strong>Description:</strong> {{ task.description }}</p>
<p><strong>Due Date:</strong> {{ task.dueDate }}</p>
</div>
<div class="task-detail__states">
<h3>State History</h3>
<ul>
@for (entry of task.stateHistory; track entry.date) {
<li>{{ entry.state }} - {{ entry.date }}</li>
}
</ul>
</div>
<div class="task-detail__notes">
<h3>Notes</h3>
<ul>
@for (note of task.notes; track $index) {
<li>
{{ note }}
<button (click)="onDeleteNote($index)">Delete</button>
</li>
}
</ul>
</div>
<div class="task-detail__actions">
<button (click)="onDeleteTask()">Delete Task</button>
</div>
}
</section>

View File

@ -0,0 +1,75 @@
import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { TaskStore } from '../../data-access/store/task-store';
import { UpdateTaskDto, TaskState } from '../../data-access/models/task.model';
import { TaskStateBadge } from '../../ui/task-state-badge/task-state-badge';
@Component({
selector: 'emi-task-detail-page',
imports: [TaskStateBadge],
templateUrl: './task-detail-page.html',
styleUrl: './task-detail-page.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TaskDetailPage implements OnInit {
private readonly store = inject(TaskStore);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
readonly task = this.store.selectedTask;
readonly loading = this.store.loading;
readonly error = this.store.error;
ngOnInit(): void {
const id = this.route.snapshot.paramMap.get('id');
if (id) {
this.store.loadTask(id);
}
}
get currentState(): TaskState | null {
const task = this.task();
if (!task || task.stateHistory.length === 0) return null;
return task.stateHistory[task.stateHistory.length - 1].state;
}
onUpdateTask(dto: UpdateTaskDto): void {
const task = this.task();
if (task) {
this.store.updateTask(task.id, dto);
}
}
onChangeState(state: TaskState): void {
const task = this.task();
if (task) {
this.store.transitionTask(task.id, state);
}
}
onDeleteTask(): void {
const task = this.task();
if (task) {
this.store.deleteTask(task.id);
this.router.navigate(['/tasks']);
}
}
onAddNote(content: string): void {
const task = this.task();
if (task) {
this.store.addNote(task.id, content);
}
}
onDeleteNote(noteIndex: number): void {
const task = this.task();
if (task) {
this.store.deleteNote(task.id, noteIndex);
}
}
onBack(): void {
this.router.navigate(['/tasks']);
}
}

View File

@ -0,0 +1,534 @@
.task-list {
padding: var(--space-8) var(--space-10);
background-color: var(--bg-surface);
transition: padding-right var(--transition-base);
}
.task-list--sidebar-open {
padding-right: calc(var(--space-10) + 420px);
}
.task-list__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-6);
margin-bottom: var(--space-5);
}
.task-list__title-row {
display: flex;
align-items: center;
gap: var(--space-3);
}
.task-list__title {
font-size: var(--fs-xl);
font-weight: var(--fw-bold);
letter-spacing: var(--tracking-tight);
color: var(--text-primary);
margin: 0;
}
.task-list__status-pill {
display: inline-flex;
align-items: center;
gap: var(--space-1);
background-color: var(--color-success);
color: var(--color-white);
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-pill);
}
.task-list__status-pill::before {
content: "";
width: 6px;
height: 6px;
background: var(--color-white);
border-radius: var(--radius-full);
}
.task-list__actions {
display: flex;
gap: var(--space-2);
}
.task-list__btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
height: 38px;
padding: 0 var(--space-3);
border-radius: var(--radius-md);
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
cursor: pointer;
border: 1px solid var(--border-subtle);
background: var(--bg-surface);
color: var(--text-primary);
}
.task-list__btn:hover {
background: var(--bg-page);
border-color: var(--border-default);
}
.task-list__btn--primary {
background: var(--color-brand-primary);
color: var(--color-white);
border-color: var(--color-brand-primary);
font-weight: var(--fw-semibold);
border-radius: var(--radius-pill);
}
.task-list__btn--primary:hover {
background: var(--color-brand-primary-hover);
border-color: var(--color-brand-primary-hover);
}
.task-list__tabs {
display: flex;
gap: var(--space-10);
border-bottom: 1px solid var(--border-subtle);
margin-bottom: var(--space-7);
}
.task-list__tab {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) 0;
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
color: var(--text-secondary);
cursor: pointer;
border: none;
background: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.task-list__tab:hover {
color: var(--text-primary);
}
.task-list__tab--active {
color: var(--color-brand-primary);
border-bottom-color: var(--color-brand-primary);
font-weight: var(--fw-semibold);
}
.task-list__progress {
margin-bottom: var(--space-5);
}
.task-list__progress-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: var(--space-2);
}
.task-list__progress-label {
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
color: var(--text-secondary);
}
.task-list__progress-value {
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
color: var(--color-brand-primary);
}
.task-list__progress-track {
height: 6px;
background: var(--border-subtle);
border-radius: var(--radius-pill);
overflow: hidden;
}
.task-list__progress-fill {
height: 100%;
background-color: var(--color-brand-primary);
border-radius: var(--radius-pill);
}
.task-list__loading,
.task-list__error {
padding: var(--space-8);
text-align: center;
font-size: var(--fs-sm);
color: var(--text-secondary);
}
.task-list__error {
color: var(--state-error);
}
.task-list__table {
width: 100%;
border-collapse: collapse;
font-size: var(--fs-sm);
}
.task-list__th {
text-align: left;
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
color: var(--text-muted);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-page);
}
.task-list__td {
padding: var(--space-4);
border-bottom: 1px solid var(--border-subtle);
vertical-align: middle;
}
.task-list__td--task {
width: 45%;
}
.task-list__row {
cursor: pointer;
transition: background var(--transition-fast);
}
.task-list__row:hover {
background: var(--bg-page);
}
.task-list__row--highlight {
background: rgba(14, 165, 233, 0.08);
}
.task-list__row--completed .task-list__task-title,
.task-list__row--completed .task-list__task-subtitle {
color: var(--text-muted);
}
.task-list__progress-cell {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 100px;
}
.task-list__progress-bar {
flex: 1;
height: 6px;
background: var(--border-subtle);
border-radius: var(--radius-pill);
overflow: hidden;
}
.task-list__progress-bar-fill {
height: 100%;
background-color: var(--color-success);
border-radius: var(--radius-pill);
}
.task-list__progress-percent {
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
color: var(--text-secondary);
min-width: 32px;
text-align: right;
}
.task-list__task-title {
display: block;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--text-primary);
line-height: var(--lh-snug);
}
.task-list__task-subtitle {
display: block;
font-size: var(--fs-xs);
color: var(--text-muted);
margin-top: 2px;
}
.task-list__badge {
display: inline-flex;
align-items: center;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-md);
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
}
.task-list__badge--active {
background: rgba(22, 163, 74, 0.12);
color: var(--color-success-dark);
}
.task-list__badge--new {
background: rgba(234, 88, 12, 0.12);
color: #ea580c;
}
.task-list__badge--resolved {
background: rgba(37, 99, 235, 0.12);
color: #2563eb;
}
.task-list__badge--closed {
background: var(--color-gray-100);
color: var(--text-secondary);
}
.task-list__due-date {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-family: var(--font-mono);
font-size: var(--fs-xs);
color: var(--text-secondary);
}
.task-list__due-date svg {
color: var(--text-muted);
}
.task-list__notes {
font-size: var(--fs-xs);
color: var(--text-muted);
}
.task-list__empty {
text-align: center;
padding: var(--space-10);
color: var(--text-muted);
}
.task-list__footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-4) var(--space-1);
font-size: var(--fs-xs);
color: var(--text-secondary);
}
.task-list__legend {
display: flex;
gap: var(--space-5);
}
.task-list__legend-item {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.task-list__dot {
width: 8px;
height: 8px;
border-radius: var(--radius-full);
}
.task-list__dot--completed {
background: var(--color-success);
}
.task-list__dot--active {
background: var(--color-brand-primary);
}
.task-list__dot--new {
background: var(--color-warning);
}
.task-list__total {
color: var(--text-muted);
}
.task-list__board {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-4);
align-items: flex-start;
}
.task-list__timeline {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.task-list__timeline-group {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.task-list__timeline-date {
display: flex;
align-items: center;
gap: var(--space-3);
}
.task-list__timeline-dot {
width: 12px;
height: 12px;
background-color: var(--color-brand-primary);
border-radius: var(--radius-full);
flex-shrink: 0;
}
.task-list__timeline-label {
font-family: var(--font-mono);
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--text-primary);
}
.task-list__timeline-items {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding-left: calc(12px + var(--space-3));
border-left: 2px solid var(--border-subtle);
margin-left: 5px;
}
.task-list__timeline-card {
background: var(--bg-page);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
cursor: pointer;
}
.task-list__timeline-card:hover {
background: var(--bg-surface);
}
.task-list__timeline-card--finalized {
opacity: 0.7;
}
.task-list__timeline-card--finalized .task-list__task-title {
color: var(--text-muted);
}
.task-list__timeline-card-header,
.task-list__timeline-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.task-list__timeline-card-header {
margin-bottom: var(--space-2);
}
.task-list__timeline-card-footer {
margin-top: var(--space-2);
}
.task-list__timeline-items {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding-left: calc(12px + var(--space-3));
border-left: 2px solid var(--border-subtle);
margin-left: 5px;
}
.task-list__timeline-card {
background: var(--bg-page);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
cursor: pointer;
}
.task-list__timeline-card:hover {
background: var(--bg-surface);
}
.task-list__timeline-card--finalized {
opacity: 0.7;
}
.task-list__timeline-card--finalized .task-list__task-title {
color: var(--text-muted);
}
.task-list__timeline-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-2);
}
.task-list__timeline-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-top: var(--space-2);
}
@media (max-width: 768px) {
.task-list {
padding: var(--space-4);
}
.task-list--sidebar-open {
padding-right: var(--space-4);
}
.task-list__header {
flex-direction: column;
align-items: flex-start;
}
.task-list__table thead {
display: none;
}
.task-list__td {
display: block;
padding: var(--space-2) var(--space-4);
border: none;
}
.task-list__row {
display: block;
padding: var(--space-3) 0;
border-bottom: 1px solid var(--border-subtle);
}
.task-list__footer {
flex-direction: column;
gap: var(--space-3);
align-items: flex-start;
}
.task-list__board {
grid-template-columns: 1fr;
}
.task-list__timeline-card-header,
.task-list__timeline-card-footer {
flex-direction: column;
align-items: flex-start;
}
}
.task-list__logo {
width: 100px;
object-fit: contain;
}

View File

@ -0,0 +1,249 @@
<section class="task-list" [class.task-list--sidebar-open]="selectedTask() || showCreateSidebar()">
<header class="task-list__header">
<div class="task-list__title-row">
<img src="/images/logo.png" alt="Logo" class="task-list__logo">
<h1 class="task-list__title">Task Board</h1>
</div>
<div class="task-list__actions">
<button class="task-list__btn task-list__btn--primary" (click)="onOpenCreateSidebar()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New Task
</button>
</div>
</header>
<nav class="task-list__tabs">
<button
class="task-list__tab"
[class.task-list__tab--active]="activeView() === 'table'"
(click)="onViewChange('table')"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
Table
</button>
<button
class="task-list__tab"
[class.task-list__tab--active]="activeView() === 'board'"
(click)="onViewChange('board')"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="18" rx="1"/><rect x="14" y="3" width="7" height="11" rx="1"/></svg>
Board
</button>
<button
class="task-list__tab"
[class.task-list__tab--active]="activeView() === 'timeline'"
(click)="onViewChange('timeline')"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
Timeline
</button>
</nav>
<div class="task-list__progress">
<div class="task-list__progress-header">
<span class="task-list__progress-label">Sprint Progress</span>
<span class="task-list__progress-value">{{ completionPercent() }}% complete</span>
</div>
<div class="task-list__progress-track">
<div class="task-list__progress-fill" [style.width.%]="completionPercent()"></div>
</div>
</div>
@if (loading()) {
<div class="task-list__loading">
<p>Loading tasks...</p>
</div>
}
@if (!loading()) {
@switch (activeView()) {
@case ('table') {
<table class="task-list__table">
<thead>
<tr>
<th class="task-list__th">Task</th>
<th class="task-list__th">Progress</th>
<th class="task-list__th">Status</th>
<th class="task-list__th">Due Date</th>
<th class="task-list__th">Notes</th>
</tr>
</thead>
<tbody>
@for (task of paginatedTasks(); track task.id) {
<tr
class="task-list__row"
[class.task-list__row--completed]="isFinalized(task)"
[class.task-list__row--highlight]="isSelected(task)"
(click)="onViewTask(task)"
>
<td class="task-list__td task-list__td--task">
<span class="task-list__task-title">{{ task.title }}</span>
<span class="task-list__task-subtitle">{{ task.description }}</span>
</td>
<td class="task-list__td">
<div class="task-list__progress-cell">
<div class="task-list__progress-bar">
<div
class="task-list__progress-bar-fill"
[style.width.%]="getStateProgress(task)"
></div>
</div>
<span class="task-list__progress-percent">{{ getStateProgress(task) }}%</span>
</div>
</td>
<td class="task-list__td">
<span class="task-list__badge task-list__badge--{{ getState(task) }}">
{{ getStateLabel(task) }}
</span>
</td>
<td class="task-list__td">
<span class="task-list__due-date">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
{{ task.dueDate }}
</span>
</td>
<td class="task-list__td">
<span class="task-list__notes">{{ task.notes.length }} {{ task.notes.length === 1 ? 'note' : 'notes' }}</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="task-list__empty">No tasks found</td>
</tr>
}
</tbody>
</table>
@if (tasks().length > PAGE_SIZE) {
<emi-pagination
[currentPage]="currentPage()"
[totalPages]="totalPages()"
[totalItems]="tasks().length"
[pageSize]="PAGE_SIZE"
(pageChange)="onPageChange($event)"
/>
}
<footer class="task-list__footer">
<div class="task-list__legend">
<span class="task-list__legend-item">
<span class="task-list__dot task-list__dot--completed"></span>
{{ completedCount() }} Completed
</span>
<span class="task-list__legend-item">
<span class="task-list__dot task-list__dot--active"></span>
{{ activeCount() }} Active
</span>
<span class="task-list__legend-item">
<span class="task-list__dot task-list__dot--new"></span>
{{ newCount() }} New
</span>
</div>
<span class="task-list__total">{{ tasks().length }} tasks total</span>
</footer>
}
@case ('board') {
<div class="task-list__board">
<emi-task-board-column
state="new"
title="New"
[tasks]="tasksByState()['new']"
(view)="onBoardViewTask($event)"
(edit)="onBoardEditTask($event)"
(delete)="onBoardDeleteTask($event)"
/>
<emi-task-board-column
state="active"
title="Active"
[tasks]="tasksByState()['active']"
(view)="onBoardViewTask($event)"
(edit)="onBoardEditTask($event)"
(delete)="onBoardDeleteTask($event)"
/>
<emi-task-board-column
state="resolved"
title="Resolved"
[tasks]="tasksByState()['resolved']"
(view)="onBoardViewTask($event)"
(edit)="onBoardEditTask($event)"
(delete)="onBoardDeleteTask($event)"
/>
<emi-task-board-column
state="closed"
title="Closed"
[tasks]="tasksByState()['closed']"
(view)="onBoardViewTask($event)"
(edit)="onBoardEditTask($event)"
(delete)="onBoardDeleteTask($event)"
/>
</div>
}
@case ('timeline') {
<div class="task-list__timeline">
@for (date of dueDates(); track date) {
<div class="task-list__timeline-group">
<div class="task-list__timeline-date">
<span class="task-list__timeline-dot"></span>
<span class="task-list__timeline-label">{{ date }}</span>
</div>
<div class="task-list__timeline-items">
@for (task of tasksByDate()[date]; track task.id) {
<div
class="task-list__timeline-card"
[class.task-list__timeline-card--finalized]="isFinalized(task)"
(click)="onViewTask(task)"
>
<div class="task-list__timeline-card-header">
<span class="task-list__task-title">{{ task.title }}</span>
<span class="task-list__badge task-list__badge--{{ getState(task) }}">
{{ getStateLabel(task) }}
</span>
</div>
@if (task.description) {
<p class="task-list__task-subtitle">{{ task.description }}</p>
}
<div class="task-list__timeline-card-footer">
<div class="task-list__progress-cell">
<div class="task-list__progress-bar">
<div
class="task-list__progress-bar-fill"
[style.width.%]="getStateProgress(task)"
></div>
</div>
<span class="task-list__progress-percent">{{ getStateProgress(task) }}%</span>
</div>
<span class="task-list__notes">{{ task.notes.length }} {{ task.notes.length === 1 ? 'note' : 'notes' }}</span>
</div>
</div>
}
</div>
</div>
} @empty {
<div class="task-list__empty">No tasks found</div>
}
</div>
}
}
}
</section>
@if (selectedTask(); as task) {
<emi-task-detail-sidebar
[task]="task"
[transitioningState]="transitioningState()"
(close)="onCloseDetailSidebar()"
(changeState)="onChangeState($event)"
(deleteTask)="onDeleteTask(task)"
(addNote)="onAddNote($event)"
(deleteNote)="onDeleteNote($event)"
/>
}
@if (showCreateSidebar()) {
<emi-task-create-sidebar
(close)="onCloseCreateSidebar()"
(created)="onTaskCreated()"
/>
}

View File

@ -0,0 +1,199 @@
import { afterNextRender, ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { TaskStore } from '../../data-access/store/task-store';
import { Task, TaskState } from '../../data-access/models/task.model';
import { TaskDetailSidebar } from '../../ui/task-detail-sidebar/task-detail-sidebar';
import { TaskCreateSidebar } from '../../ui/task-create-sidebar/task-create-sidebar';
import { TaskBoardColumn } from '../../ui/task-board-column/task-board-column';
import { Pagination } from '@shared/ui/pagination/pagination';
export type ViewMode = 'table' | 'board' | 'timeline';
@Component({
selector: 'emi-task-list-page',
imports: [TaskDetailSidebar, TaskCreateSidebar, TaskBoardColumn, Pagination],
templateUrl: './task-list-page.html',
styleUrl: './task-list-page.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TaskListPage {
private readonly store = inject(TaskStore);
readonly PAGE_SIZE = 5;
constructor() {
afterNextRender(() => {
this.store.loadTasks();
});
}
readonly tasks = this.store.filteredTasks;
readonly loading = this.store.loading;
readonly selectedTask = this.store.selectedTask;
readonly transitioningState = this.store.transitioningState;
readonly showCreateSidebar = signal(false);
readonly activeView = signal<ViewMode>('table');
readonly currentPage = signal(1);
private readonly stateProgress: Record<TaskState, number> = {
new: 0,
active: 50,
resolved: 100,
closed: 100,
};
private readonly stateLabels: Record<TaskState, string> = {
new: 'New',
active: 'Active',
resolved: 'Resolved',
closed: 'Closed',
};
readonly totalPages = computed(() => {
return Math.max(1, Math.ceil(this.tasks().length / this.PAGE_SIZE));
});
readonly paginatedTasks = computed(() => {
const start = (this.currentPage() - 1) * this.PAGE_SIZE;
return this.tasks().slice(start, start + this.PAGE_SIZE);
});
readonly completionPercent = computed(() => {
const all = this.tasks();
if (all.length === 0) return 0;
const totalProgress = all.reduce((sum, t) => sum + this.getStateProgress(t), 0);
return Math.round(totalProgress / all.length);
});
readonly completedCount = computed(() =>
this.tasks().filter(t => this.isFinalized(t)).length
);
readonly activeCount = computed(() =>
this.tasks().filter(t => this.getState(t) === 'active').length
);
readonly newCount = computed(() =>
this.tasks().filter(t => this.getState(t) === 'new').length
);
readonly tasksByState = computed(() => {
const grouped: Record<TaskState, Task[]> = {
new: [],
active: [],
resolved: [],
closed: [],
};
for (const task of this.tasks()) {
const state = this.getState(task);
grouped[state].push(task);
}
return grouped;
});
readonly tasksByDate = computed(() => {
const sorted = [...this.tasks()].sort((a, b) =>
new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime()
);
const grouped: Record<string, Task[]> = {};
for (const task of sorted) {
const date = task.dueDate;
if (!grouped[date]) {
grouped[date] = [];
}
grouped[date].push(task);
}
return grouped;
});
readonly dueDates = computed(() => Object.keys(this.tasksByDate()));
onPageChange(page: number): void {
this.currentPage.set(page);
}
onViewChange(view: ViewMode): void {
this.activeView.set(view);
}
getState(task: Task): TaskState {
const history = task.stateHistory;
return history[history.length - 1]?.state ?? 'new';
}
getStateLabel(task: Task): string {
return this.stateLabels[this.getState(task)];
}
getStateProgress(task: Task): number {
return this.stateProgress[this.getState(task)];
}
isFinalized(task: Task): boolean {
const state = this.getState(task);
return state === 'resolved' || state === 'closed';
}
isSelected(task: Task): boolean {
return this.selectedTask()?.id === task.id;
}
onViewTask(task: Task): void {
this.showCreateSidebar.set(false);
this.store.setSelectedTask(task);
}
onCloseDetailSidebar(): void {
this.store.setSelectedTask(null);
}
onChangeState(state: TaskState): void {
const task = this.selectedTask();
if (task) {
this.store.transitionTask(task.id, state);
}
}
onDeleteTask(task: Task): void {
this.store.deleteTask(task.id);
this.store.setSelectedTask(null);
}
onAddNote(content: string): void {
const task = this.selectedTask();
if (task) {
this.store.addNote(task.id, content);
}
}
onDeleteNote(index: number): void {
const task = this.selectedTask();
if (task) {
this.store.deleteNote(task.id, index);
}
}
onOpenCreateSidebar(): void {
this.store.setSelectedTask(null);
this.showCreateSidebar.set(true);
}
onCloseCreateSidebar(): void {
this.showCreateSidebar.set(false);
}
onTaskCreated(): void {
this.showCreateSidebar.set(false);
}
onBoardViewTask(task: Task): void {
this.onViewTask(task);
}
onBoardEditTask(task: Task): void {
this.onViewTask(task);
}
onBoardDeleteTask(task: Task): void {
this.onDeleteTask(task);
}
}

View File

@ -0,0 +1,23 @@
import { Routes } from '@angular/router';
import { taskResolver } from './data-access/resolvers/task-resolver';
import { unsavedChangesGuard } from '@core/index';
export const TASK_ROUTES: Routes = [
{
path: '',
loadComponent: () =>
import('./feature/task-list-page/task-list-page').then(m => m.TaskListPage),
},
{
path: 'create',
loadComponent: () =>
import('./feature/task-create-page/task-create-page').then(m => m.TaskCreatePage),
canDeactivate: [unsavedChangesGuard],
},
{
path: ':id',
loadComponent: () =>
import('./feature/task-detail-page/task-detail-page').then(m => m.TaskDetailPage),
resolve: { task: taskResolver },
},
];

View File

@ -0,0 +1,164 @@
.board-column {
display: flex;
flex-direction: column;
background-color: var(--bg-page);
border-radius: var(--radius-lg);
min-width: 272px;
max-height: calc(100vh - 180px);
border: 1px solid var(--border-subtle);
}
.board-column__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border-subtle);
}
.board-column__header-left {
display: flex;
align-items: center;
gap: var(--space-2);
}
.board-column__dot {
width: 10px;
height: 10px;
border-radius: var(--radius-full);
flex-shrink: 0;
}
.board-column--new .board-column__dot {
background-color: var(--text-muted);
}
.board-column--active .board-column__dot {
background-color: var(--color-info);
}
.board-column--resolved .board-column__dot {
background-color: var(--color-warning);
}
.board-column--closed .board-column__dot {
background-color: var(--color-success);
}
.board-column__title {
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--text-primary);
margin: 0;
}
.board-column__count {
font-size: var(--fs-xs);
font-weight: var(--fw-medium);
color: var(--text-muted);
background-color: var(--color-gray-200);
padding: 1px var(--space-2);
border-radius: var(--radius-pill);
min-width: 20px;
text-align: center;
line-height: 1.6;
}
.board-column__add-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: var(--radius-md);
background: none;
cursor: pointer;
color: var(--text-muted);
transition: background var(--transition-fast), color var(--transition-fast);
}
.board-column__add-btn:hover {
background: var(--color-gray-200);
color: var(--text-primary);
}
.board-column__add-btn:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.board-column__body {
flex: 1;
overflow-y: auto;
padding: var(--space-2);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.board-column__card {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: var(--space-3);
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.board-column__card:hover {
background: var(--bg-surface);
border-color: var(--border-default);
box-shadow: var(--shadow-sm);
}
.board-column__card-header {
margin-bottom: var(--space-1);
}
.board-column__card-title {
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--text-primary);
line-height: var(--lh-snug);
}
.board-column__card-desc {
font-size: var(--fs-xs);
color: var(--text-muted);
margin: 0 0 var(--space-2);
line-height: var(--lh-normal);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.board-column__card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.board-column__card-date {
display: inline-flex;
align-items: center;
gap: var(--space-1);
font-family: var(--font-mono);
font-size: var(--fs-xs);
color: var(--text-muted);
}
.board-column__card-notes {
font-size: var(--fs-xs);
color: var(--text-muted);
}
.board-column__empty {
padding: var(--space-4);
text-align: center;
color: var(--text-muted);
font-size: var(--fs-sm);
font-style: italic;
}

View File

@ -0,0 +1,37 @@
<div class="board-column" [class]="stateClass">
<header class="board-column__header">
<div class="board-column__header-left">
<span class="board-column__dot"></span>
<h3 class="board-column__title">{{ title() }}</h3>
<span class="board-column__count">{{ tasks().length }}</span>
</div>
<button class="board-column__add-btn" (click)="onAdd()" aria-label="Add task">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
</header>
<div class="board-column__body">
@for (task of tasks(); track task.id) {
<div class="board-column__card" (click)="onView(task)">
<div class="board-column__card-header">
<span class="board-column__card-title">{{ task.title }}</span>
</div>
@if (task.description) {
<p class="board-column__card-desc">{{ task.description }}</p>
}
<div class="board-column__card-footer">
<span class="board-column__card-date">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
{{ task.dueDate }}
</span>
<span class="board-column__card-notes">{{ task.notes.length }} {{ task.notes.length === 1 ? 'note' : 'notes' }}</span>
</div>
</div>
} @empty {
<div class="board-column__empty">No tasks</div>
}
</div>
</div>

View File

@ -0,0 +1,40 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { Task, TaskState } from '../../data-access/models/task.model';
@Component({
selector: 'emi-task-board-column',
imports: [],
templateUrl: './task-board-column.html',
styleUrl: './task-board-column.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TaskBoardColumn {
state = input.required<TaskState>();
title = input.required<string>();
tasks = input.required<Task[]>();
view = output<Task>();
edit = output<Task>();
delete = output<Task>();
add = output<void>();
get stateClass(): string {
return `board-column--${this.state()}`;
}
onView(task: Task): void {
this.view.emit(task);
}
onEdit(task: Task): void {
this.edit.emit(task);
}
onDelete(task: Task): void {
this.delete.emit(task);
}
onAdd(): void {
this.add.emit();
}
}

View File

@ -0,0 +1,18 @@
<article class="task-card">
<header class="task-card__header">
<h3>{{ task().title }}</h3>
<emi-task-state-badge [state]="currentState" />
</header>
<p class="task-card__description">{{ task().description }}</p>
<footer class="task-card__footer">
<span class="task-card__date">Due: {{ task().dueDate }}</span>
</footer>
<div class="task-card__actions">
<button (click)="onView()">View</button>
<button (click)="onEdit()">Edit</button>
<button (click)="onDelete()">Delete</button>
</div>
</article>

View File

@ -0,0 +1,34 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { Task, TaskState } from '../../data-access/models/task.model';
import { TaskStateBadge } from '../task-state-badge/task-state-badge';
@Component({
selector: 'emi-task-card',
imports: [TaskStateBadge],
templateUrl: './task-card.html',
styleUrl: './task-card.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TaskCard {
task = input.required<Task>();
view = output<Task>();
edit = output<Task>();
delete = output<Task>();
get currentState(): TaskState {
const history = this.task().stateHistory;
return history[history.length - 1]?.state ?? 'new';
}
onView(): void {
this.view.emit(this.task());
}
onEdit(): void {
this.edit.emit(this.task());
}
onDelete(): void {
this.delete.emit(this.task());
}
}

View File

@ -0,0 +1,181 @@
.sidebar {
position: fixed;
top: 0;
right: 0;
width: 400px;
height: 100vh;
background-color: var(--bg-surface);
border-left: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
box-shadow: var(--shadow-xl);
z-index: var(--z-modal);
animation: slideIn var(--transition-base) ease-out;
}
@keyframes slideIn {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
.sidebar__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border-subtle);
}
.sidebar__title {
margin: 0;
font-family: var(--font-display);
font-size: var(--fs-lg);
font-weight: var(--fw-semibold);
color: var(--text-primary);
}
.sidebar__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-muted);
border-radius: var(--radius-md);
transition: all var(--transition-fast);
}
.sidebar__close:hover {
background-color: var(--color-gray-100);
color: var(--text-primary);
}
.sidebar__close:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.sidebar__form {
flex: 1;
overflow-y: auto;
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.sidebar__error {
padding: var(--space-3) var(--space-4);
background-color: rgba(220, 38, 38, 0.1);
color: var(--state-error);
border-radius: var(--radius-md);
font-size: var(--fs-sm);
}
.sidebar__field {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.sidebar__label {
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
color: var(--text-secondary);
}
.sidebar__input,
.sidebar__textarea {
padding: var(--space-3) var(--space-4);
font-size: var(--fs-base);
font-family: var(--font-body);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
background-color: var(--bg-surface);
color: var(--text-primary);
transition: border-color var(--transition-fast);
min-height: 44px;
}
.sidebar__input:focus,
.sidebar__textarea:focus {
outline: none;
border-color: var(--border-focus);
box-shadow: 0 0 0 3px rgba(230, 57, 70, 0.15);
}
.sidebar__textarea {
resize: vertical;
min-height: 80px;
}
.sidebar__actions {
display: flex;
gap: var(--space-3);
margin-top: auto;
padding-top: var(--space-4);
border-top: 1px solid var(--border-subtle);
}
.sidebar__btn {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--space-3) var(--space-6);
font-size: var(--fs-base);
font-family: var(--font-body);
font-weight: var(--fw-semibold);
border: none;
border-radius: var(--radius-pill);
cursor: pointer;
transition: all var(--transition-base);
min-height: 44px;
}
.sidebar__btn:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.sidebar__btn--primary {
background-color: var(--color-brand-primary);
color: var(--color-white);
box-shadow: var(--shadow-brand);
}
.sidebar__btn--primary:hover:not(:disabled) {
background-color: var(--color-brand-primary-hover);
transform: translateY(-1px);
}
.sidebar__btn--primary:active:not(:disabled) {
background-color: var(--color-brand-primary-active);
transform: translateY(0);
}
.sidebar__btn--primary:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.sidebar__btn--ghost {
background-color: transparent;
color: var(--text-secondary);
border-radius: var(--radius-pill);
}
.sidebar__btn--ghost:hover:not(:disabled) {
background-color: var(--color-gray-100);
color: var(--text-primary);
}

View File

@ -0,0 +1,70 @@
<aside class="sidebar">
<header class="sidebar__header">
<h2 class="sidebar__title">New Task</h2>
<button class="sidebar__close" (click)="onClose()" aria-label="Close sidebar">
&times;
</button>
</header>
<form class="sidebar__form" [formGroup]="form" (ngSubmit)="onSubmit()">
@if (error()) {
<div class="sidebar__error" role="alert">{{ error() }}</div>
}
<div class="sidebar__field">
<label class="sidebar__label" for="create-title">Title *</label>
<input
class="sidebar__input"
id="create-title"
formControlName="title"
type="text"
placeholder="Enter task title"
/>
</div>
<div class="sidebar__field">
<label class="sidebar__label" for="create-description">Description</label>
<textarea
class="sidebar__textarea"
id="create-description"
formControlName="description"
placeholder="Enter task description"
rows="4"
></textarea>
</div>
<div class="sidebar__field">
<label class="sidebar__label" for="create-dueDate">Due Date</label>
<input
class="sidebar__input"
id="create-dueDate"
formControlName="dueDate"
type="date"
/>
</div>
<div class="sidebar__field">
<label class="sidebar__label" for="create-initialNote">Initial Note *</label>
<textarea
class="sidebar__textarea"
id="create-initialNote"
formControlName="initialNote"
placeholder="Add an initial note for this task"
rows="3"
></textarea>
</div>
<div class="sidebar__actions">
<button type="button" class="sidebar__btn sidebar__btn--ghost" (click)="onClose()">
Cancel
</button>
<button
type="submit"
class="sidebar__btn sidebar__btn--primary"
[disabled]="loading() || form.invalid"
>
{{ loading() ? 'Creating...' : 'Create Task' }}
</button>
</div>
</form>
</aside>

View File

@ -0,0 +1,42 @@
import { ChangeDetectionStrategy, Component, inject, output } from '@angular/core';
import { NonNullableFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { TaskStore } from '../../data-access/store/task-store';
import { CreateTaskDto } from '../../data-access/models/task.model';
import { trimmedRequiredValidator } from '@shared/index';
@Component({
selector: 'emi-task-create-sidebar',
imports: [ReactiveFormsModule],
templateUrl: './task-create-sidebar.html',
styleUrl: './task-create-sidebar.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TaskCreateSidebar {
private readonly store = inject(TaskStore);
private readonly fb = inject(NonNullableFormBuilder);
readonly loading = this.store.loading;
readonly error = this.store.error;
close = output<void>();
created = output<void>();
readonly form = this.fb.group({
title: ['', [trimmedRequiredValidator, Validators.maxLength(100)]],
description: ['', Validators.maxLength(500)],
dueDate: this.fb.control<string>(new Date().toISOString().split('T')[0]),
initialNote: ['', [trimmedRequiredValidator, Validators.maxLength(500)]],
});
onClose(): void {
this.close.emit();
}
onSubmit(): void {
if (this.form.valid) {
const dto: CreateTaskDto = this.form.getRawValue();
this.store.createTask(dto);
this.created.emit();
}
}
}

View File

@ -0,0 +1,292 @@
.task-sidebar {
position: fixed;
top: 0;
right: 0;
width: 420px;
height: 100vh;
background-color: var(--bg-surface);
border-left: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
box-shadow: var(--shadow-xl);
z-index: var(--z-modal);
animation: task-sidebar-slide-in var(--transition-base) var(--ease-out);
}
@keyframes task-sidebar-slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.task-sidebar {
animation-duration: 0.01ms;
}
}
.task-sidebar__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-6);
border-bottom: 1px solid var(--border-subtle);
}
.task-sidebar__title {
margin: 0;
font-size: var(--fs-lg);
font-weight: var(--fw-bold);
color: var(--text-primary);
line-height: var(--lh-snug);
}
.task-sidebar__close {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: none;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
cursor: pointer;
color: var(--text-secondary);
transition: background var(--transition-fast), color var(--transition-fast);
flex-shrink: 0;
}
.task-sidebar__close:hover {
background: var(--bg-page);
color: var(--text-primary);
}
.task-sidebar__close:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.task-sidebar__body {
flex: 1;
overflow-y: auto;
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.task-sidebar__section {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.task-sidebar__label {
margin: 0;
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
color: var(--text-muted);
}
.task-sidebar__text {
margin: 0;
font-size: var(--fs-sm);
color: var(--text-primary);
line-height: var(--lh-normal);
}
.task-sidebar__text--muted {
color: var(--text-muted);
font-style: italic;
}
.task-sidebar__meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
}
.task-sidebar__meta-item {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.task-sidebar__meta-value {
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
color: var(--text-primary);
font-family: var(--font-mono);
}
.task-sidebar__history {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.task-sidebar__history-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-2) var(--space-3);
background: var(--bg-page);
border-radius: var(--radius-md);
font-size: var(--fs-xs);
}
.task-sidebar__history-state {
font-weight: var(--fw-semibold);
text-transform: capitalize;
color: var(--text-primary);
}
.task-sidebar__history-date {
color: var(--text-muted);
font-family: var(--font-mono);
font-size: var(--fs-xs);
}
.task-sidebar__footer {
padding: var(--space-4) var(--space-6);
border-top: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
gap: var(--space-4);
background: var(--bg-page);
}
.task-sidebar__states {
display: flex;
gap: var(--space-2);
}
.task-sidebar__state-btn {
flex: 1;
padding: var(--space-2) var(--space-3);
font-size: var(--fs-xs);
font-weight: var(--fw-medium);
text-transform: capitalize;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--bg-surface);
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
min-height: 36px;
}
.task-sidebar__state-btn:hover {
background: var(--bg-page);
border-color: var(--border-default);
color: var(--text-primary);
}
.task-sidebar__state-btn:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.task-sidebar__state-btn--active {
background: var(--color-brand-primary);
color: var(--color-white);
border-color: var(--color-brand-primary);
font-weight: var(--fw-semibold);
}
.task-sidebar__state-btn--active:hover {
background: var(--color-brand-primary-hover);
border-color: var(--color-brand-primary-hover);
color: var(--color-white);
}
.task-sidebar__state-btn--disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
.task-sidebar__state-btn--loading {
opacity: 1;
cursor: wait;
pointer-events: none;
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
}
.task-sidebar__state-btn-text--hidden {
visibility: hidden;
height: 0;
overflow: hidden;
}
.task-sidebar__actions {
display: flex;
gap: var(--space-2);
}
.task-sidebar__action-btn {
flex: 1;
padding: var(--space-2) var(--space-4);
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--bg-surface);
color: var(--text-primary);
cursor: pointer;
transition: all var(--transition-fast);
min-height: 40px;
}
.task-sidebar__action-btn:hover {
background: var(--bg-page);
border-color: var(--border-default);
}
.task-sidebar__action-btn:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.task-sidebar__action-btn--danger {
color: var(--color-danger);
border-color: var(--color-danger);
}
.task-sidebar__action-btn--danger:hover {
background: var(--color-danger);
color: var(--color-white);
}
@media (max-width: 480px) {
.task-sidebar {
width: 100%;
}
.task-sidebar__meta {
grid-template-columns: 1fr;
}
.task-sidebar__states {
flex-wrap: wrap;
}
.task-sidebar__state-btn {
flex: 1 1 calc(50% - var(--space-1));
}
}

View File

@ -0,0 +1,82 @@
<aside class="task-sidebar">
<header class="task-sidebar__header">
<h2 class="task-sidebar__title">{{ task().title }}</h2>
<button class="task-sidebar__close" (click)="onClose()" aria-label="Close sidebar">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</header>
<div class="task-sidebar__body">
<div class="task-sidebar__section">
<emi-task-state-badge [state]="currentState()" />
</div>
<div class="task-sidebar__section">
<h3 class="task-sidebar__label">Description</h3>
<p class="task-sidebar__text" [class.task-sidebar__text--muted]="!task().description">
{{ task().description || 'No description provided' }}
</p>
</div>
<div class="task-sidebar__meta">
<div class="task-sidebar__meta-item">
<h3 class="task-sidebar__label">Due Date</h3>
<span class="task-sidebar__meta-value">{{ task().dueDate }}</span>
</div>
<div class="task-sidebar__meta-item">
<h3 class="task-sidebar__label">Progress</h3>
<span class="task-sidebar__meta-value">{{ getStateProgress() }}%</span>
</div>
</div>
<div class="task-sidebar__section">
<h3 class="task-sidebar__label">State History</h3>
<ul class="task-sidebar__history">
@for (entry of task().stateHistory; track entry.date) {
<li class="task-sidebar__history-item">
<span class="task-sidebar__history-state">{{ entry.state }}</span>
<span class="task-sidebar__history-date">{{ entry.date }}</span>
</li>
}
</ul>
</div>
<div class="task-sidebar__section">
<h3 class="task-sidebar__label">Notes</h3>
<emi-task-note-list
[notes]="task().notes"
(addNote)="onAddNote($event)"
(deleteNote)="onDeleteNote($event)"
/>
</div>
</div>
<footer class="task-sidebar__footer">
<div class="task-sidebar__states">
@for (state of allStates; track state) {
<button
class="task-sidebar__state-btn"
[class.task-sidebar__state-btn--active]="isCurrentState(state)"
[class.task-sidebar__state-btn--disabled]="isCurrentState(state) || isTransitioning()"
[class.task-sidebar__state-btn--loading]="isTransitioningTo(state)"
[disabled]="isCurrentState(state) || isTransitioning()"
(click)="onChangeState(state)"
>
@if (isTransitioningTo(state)) {
<emi-spinner size="sm" />
}
<span [class.task-sidebar__state-btn-text--hidden]="isTransitioningTo(state)">{{ state }}</span>
</button>
}
</div>
<div class="task-sidebar__actions">
<button class="task-sidebar__action-btn task-sidebar__action-btn--danger" [disabled]="isTransitioning()" (click)="onDeleteTask()">
Delete
</button>
</div>
</footer>
</aside>

View File

@ -0,0 +1,74 @@
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { Task, TaskState } from '../../data-access/models/task.model';
import { TaskStateBadge } from '../task-state-badge/task-state-badge';
import { TaskNoteList } from '../task-note-list/task-note-list';
import { Spinner } from '@shared/ui/spinner/spinner';
@Component({
selector: 'emi-task-detail-sidebar',
imports: [TaskStateBadge, TaskNoteList, Spinner],
templateUrl: './task-detail-sidebar.html',
styleUrl: './task-detail-sidebar.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TaskDetailSidebar {
task = input.required<Task>();
transitioningState = input<TaskState | null>(null);
close = output<void>();
changeState = output<TaskState>();
deleteTask = output<void>();
addNote = output<string>();
deleteNote = output<number>();
private readonly stateProgress: Record<TaskState, number> = {
new: 0,
active: 50,
resolved: 100,
closed: 100,
};
readonly allStates: TaskState[] = ['new', 'active', 'resolved', 'closed'];
readonly currentState = computed<TaskState>(() => {
const task = this.task();
if (task.stateHistory.length === 0) return 'new';
return task.stateHistory[task.stateHistory.length - 1].state;
});
isCurrentState(state: TaskState): boolean {
return this.currentState() === state;
}
isTransitioningTo(state: TaskState): boolean {
return this.transitioningState() === state;
}
isTransitioning(): boolean {
return this.transitioningState() !== null;
}
getStateProgress(): number {
return this.stateProgress[this.currentState()];
}
onClose(): void {
this.close.emit();
}
onChangeState(state: TaskState): void {
this.changeState.emit(state);
}
onDeleteTask(): void {
this.deleteTask.emit();
}
onAddNote(content: string): void {
this.addNote.emit(content);
}
onDeleteNote(index: number): void {
this.deleteNote.emit(index);
}
}

View File

@ -0,0 +1 @@
<p>task-form works!</p>

View File

@ -0,0 +1,30 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { CreateTaskDto, UpdateTaskDto } from '../../data-access/models/task.model';
@Component({
selector: 'emi-task-form',
imports: [ReactiveFormsModule],
templateUrl: './task-form.html',
styleUrl: './task-form.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TaskForm {
form = input.required<FormGroup>();
isEditing = input(false);
submitLabel = input('Save');
loading = input(false);
submitted = output<CreateTaskDto | UpdateTaskDto>();
cancelled = output<void>();
onSubmit(): void {
if (this.form().valid) {
this.submitted.emit(this.form().getRawValue());
}
}
onCancel(): void {
this.cancelled.emit();
}
}

View File

@ -0,0 +1,77 @@
.note-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.note-list__item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3);
background: var(--bg-page);
border-radius: var(--radius-md);
font-size: var(--fs-sm);
color: var(--text-primary);
line-height: var(--lh-normal);
transition: background var(--transition-fast);
}
.note-list__item:hover {
background: var(--color-gray-100);
}
.note-list__content {
flex: 1;
word-break: break-word;
}
.note-list__delete {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: none;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--text-muted);
font-size: var(--fs-sm);
flex-shrink: 0;
transition: background var(--transition-fast), color var(--transition-fast);
}
.note-list__delete:hover {
background: rgba(220, 38, 38, 0.1);
color: var(--color-danger);
}
.note-list__delete:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.note-list__empty {
padding: var(--space-4);
text-align: center;
color: var(--text-muted);
font-size: var(--fs-sm);
font-style: italic;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}

View File

@ -0,0 +1,17 @@
<ul class="note-list" role="list" aria-label="Task notes">
@for (note of notes(); track $index) {
<li class="note-list__item" role="listitem">
<span class="note-list__content">{{ note }}</span>
<button
class="note-list__delete"
(click)="onDeleteNote($index)"
[attr.aria-label]="'Delete note: ' + note"
>
<span aria-hidden="true">&times;</span>
<span class="sr-only">Delete note</span>
</button>
</li>
} @empty {
<li class="note-list__empty" role="listitem">No notes yet</li>
}
</ul>

View File

@ -0,0 +1,26 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
@Component({
selector: 'emi-task-note-list',
imports: [],
templateUrl: './task-note-list.html',
styleUrl: './task-note-list.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TaskNoteList {
notes = input.required<string[]>();
loading = input(false);
addNote = output<string>();
deleteNote = output<number>();
onAddNote(content: string): void {
if (content.trim()) {
this.addNote.emit(content);
}
}
onDeleteNote(index: number): void {
this.deleteNote.emit(index);
}
}

View File

@ -0,0 +1,43 @@
@keyframes badge-pop {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
.state-badge {
display: inline-flex;
align-items: center;
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-pill);
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
text-transform: capitalize;
transition: background var(--transition-base), color var(--transition-base), box-shadow var(--transition-base);
animation: badge-pop var(--transition-base) var(--ease-out);
}
.state-badge--new {
background: var(--color-gray-100);
color: var(--color-gray-600);
}
.state-badge--active {
background: #FEF3C7;
color: #92400E;
}
.state-badge--resolved {
background: #D1FAE5;
color: #065F46;
}
.state-badge--closed {
background: var(--color-gray-200);
color: var(--color-gray-500);
}

View File

@ -0,0 +1,7 @@
<span
class="state-badge state-badge--{{ state() }}"
role="status"
[attr.aria-label]="'Task state: ' + stateLabel"
>
{{ stateLabel }}
</span>

View File

@ -0,0 +1,23 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { TaskState } from '../../data-access/models/task.model';
@Component({
selector: 'emi-task-state-badge',
imports: [],
templateUrl: './task-state-badge.html',
styleUrl: './task-state-badge.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TaskStateBadge {
state = input.required<TaskState>();
get stateLabel(): string {
const labels: Record<TaskState, string> = {
new: 'New',
active: 'Active',
resolved: 'Resolved',
closed: 'Closed',
};
return labels[this.state()];
}
}

0
src/app/shared/.gitkeep Normal file
View File

14
src/app/shared/index.ts Normal file
View File

@ -0,0 +1,14 @@
export { Button } from './ui/button/button';
export { Card } from './ui/card/card';
export { FormField } from './ui/form-field/form-field';
export { Pagination } from './ui/pagination/pagination';
export { Modal } from './ui/modal/modal';
export { Spinner } from './ui/spinner/spinner';
export { NotFound } from './ui/not-found/not-found';
export { SafeDatePipe } from './pipes/safe-date-pipe';
export { futureDateValidator, atLeastOneFilledValidator, trimmedRequiredValidator } from './utils/form-validators.util';
export { toISODateString, isFutureDate, formatRelative } from './utils/date.util';
export type { Result } from './models/result.model';
export { ok, err, isOk, isErr } from './models/result.model';

View File

@ -0,0 +1,19 @@
export type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
export function ok<T>(data: T): Result<T, never> {
return { success: true, data };
}
export function err<E>(error: E): Result<never, E> {
return { success: false, error };
}
export function isOk<T, E>(result: Result<T, E>): result is { success: true; data: T } {
return result.success;
}
export function isErr<T, E>(result: Result<T, E>): result is { success: false; error: E } {
return !result.success;
}

View File

@ -0,0 +1,12 @@
import { Pipe, PipeTransform } from '@angular/core';
import { formatRelative } from '../utils/date.util';
@Pipe({
name: 'safeDate',
})
export class SafeDatePipe implements PipeTransform {
transform(value: string | Date | null): string {
if (!value) return '';
return formatRelative(value);
}
}

View File

@ -0,0 +1,86 @@
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
font-family: var(--font-body);
font-weight: var(--fw-semibold);
border: none;
cursor: pointer;
transition: all var(--transition-base);
text-decoration: none;
min-height: 44px;
padding: var(--space-3) var(--space-6);
font-size: var(--fs-base);
border-radius: var(--radius-pill);
}
.btn:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn--primary {
background-color: var(--color-brand-primary);
color: var(--color-white);
box-shadow: var(--shadow-brand);
}
.btn--primary:hover:not(:disabled) {
background-color: var(--color-brand-primary-hover);
transform: translateY(-1px);
}
.btn--primary:active:not(:disabled) {
background-color: var(--color-brand-primary-active);
transform: translateY(0);
}
.btn--outline {
background-color: transparent;
color: var(--color-brand-primary);
border: 2px solid var(--color-brand-primary);
}
.btn--outline:hover:not(:disabled) {
background-color: var(--color-brand-primary);
color: var(--color-white);
}
.btn--ghost {
background-color: transparent;
color: var(--text-secondary);
}
.btn--ghost:hover:not(:disabled) {
background-color: var(--color-gray-100);
color: var(--text-primary);
}
.btn--danger {
background-color: transparent;
color: var(--color-danger);
border: 1px solid var(--color-danger);
}
.btn--danger:hover:not(:disabled) {
background-color: var(--color-danger);
color: var(--color-white);
}
.btn--sm {
padding: var(--space-2) var(--space-4);
font-size: var(--fs-sm);
min-height: 36px;
}
.btn--lg {
padding: var(--space-4) var(--space-8);
font-size: var(--fs-md);
min-height: 52px;
}

View File

@ -0,0 +1,14 @@
<button
class="btn"
[class.btn--primary]="variant() === 'primary'"
[class.btn--outline]="variant() === 'outline'"
[class.btn--ghost]="variant() === 'ghost'"
[class.btn--danger]="variant() === 'danger'"
[class.btn--sm]="size() === 'sm'"
[class.btn--lg]="size() === 'lg'"
[disabled]="disabled()"
[type]="buttonType()"
(click)="onClick()"
>
<ng-content />
</button>

View File

@ -0,0 +1,20 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
@Component({
selector: 'emi-button',
imports: [],
templateUrl: './button.html',
styleUrl: './button.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Button {
variant = input<'primary' | 'outline' | 'ghost' | 'danger'>('primary');
size = input<'sm' | 'md' | 'lg'>('md');
disabled = input(false);
buttonType = input<'button' | 'submit' | 'reset'>('button');
clicked = output<void>();
onClick(): void {
this.clicked.emit();
}
}

View File

@ -0,0 +1,31 @@
.card {
background-color: var(--bg-surface);
border-radius: var(--radius-lg);
padding: var(--space-4);
box-shadow: var(--shadow-sm);
transition: box-shadow var(--transition-base);
}
.card:hover {
box-shadow: var(--shadow-md);
}
.card--elevated {
box-shadow: var(--shadow-lg);
}
.card--bordered {
border: 1px solid var(--border-subtle);
box-shadow: none;
}
.card--inverse {
background-color: var(--bg-inverse);
color: var(--text-inverse);
}
@media (min-width: 768px) {
.card {
padding: var(--space-6);
}
}

View File

@ -0,0 +1,8 @@
<div
class="card"
[class.card--elevated]="elevated()"
[class.card--bordered]="bordered()"
[class.card--inverse]="inverse()"
>
<ng-content />
</div>

View File

@ -0,0 +1,14 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
@Component({
selector: 'emi-card',
imports: [],
templateUrl: './card.html',
styleUrl: './card.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Card {
elevated = input(false);
bordered = input(false);
inverse = input(false);
}

View File

@ -0,0 +1,49 @@
.form-field {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.form-field--error .form-field__input {
border-color: var(--state-error);
}
.form-field__label {
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
color: var(--text-secondary);
}
.form-field__input {
padding: var(--space-3) var(--space-4);
font-size: var(--fs-base);
font-family: var(--font-body);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
background-color: var(--bg-surface);
color: var(--text-primary);
transition: border-color var(--transition-fast);
min-height: 44px;
width: 100%;
}
.form-field__input:focus {
outline: none;
border-color: var(--border-focus);
box-shadow: 0 0 0 3px rgba(230, 57, 70, 0.15);
}
.form-field__input::placeholder {
color: var(--text-muted);
}
.form-field__error {
font-size: var(--fs-xs);
color: var(--state-error);
}
.form-field__helper {
font-size: var(--fs-xs);
color: var(--text-muted);
}

View File

@ -0,0 +1,15 @@
<div class="form-field" [class.form-field--error]="hasError()">
<label class="form-field__label" [for]="inputId()">
{{ label() }}
@if (required()) {
<span class="form-field__required">*</span>
}
</label>
<ng-content select="input, textarea, select" />
@if (hasError() && errorMessage()) {
<span class="form-field__error">{{ errorMessage() }}</span>
}
@if (helperText()) {
<span class="form-field__helper">{{ helperText() }}</span>
}
</div>

View File

@ -0,0 +1,17 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
@Component({
selector: 'emi-form-field',
imports: [],
templateUrl: './form-field.html',
styleUrl: './form-field.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FormField {
inputId = input.required<string>();
label = input.required<string>();
required = input(false);
hasError = input(false);
errorMessage = input<string>();
helperText = input<string>();
}

View File

View File

@ -0,0 +1 @@
<p>modal works!</p>

View File

@ -0,0 +1,10 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'emi-modal',
imports: [],
templateUrl: './modal.html',
styleUrl: './modal.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Modal {}

View File

@ -0,0 +1,29 @@
.not-found {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
padding: var(--space-8);
text-align: center;
}
.not-found__code {
font-family: var(--font-display);
font-size: var(--fs-3xl);
font-weight: var(--fw-bold);
color: var(--color-brand-primary);
margin-bottom: var(--space-4);
}
.not-found__title {
font-size: var(--fs-xl);
margin-bottom: var(--space-4);
}
.not-found__description {
font-size: var(--fs-md);
color: var(--text-secondary);
margin-bottom: var(--space-8);
max-width: 480px;
}

View File

@ -0,0 +1,10 @@
<section class="not-found" aria-label="Page not found">
<span class="not-found__code" aria-hidden="true">404</span>
<h1 class="not-found__title">Page not found</h1>
<p class="not-found__description">
The page you're looking for doesn't exist or has been moved.
</p>
<a routerLink="/tasks" class="btn btn--primary" aria-label="Go to task list">
Go to Tasks
</a>
</section>

View File

@ -0,0 +1,11 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'emi-not-found',
imports: [RouterLink],
templateUrl: './not-found.html',
styleUrl: './not-found.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NotFound {}

View File

@ -0,0 +1,79 @@
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) 0;
gap: var(--space-4);
flex-wrap: wrap;
}
.pagination__info {
font-size: var(--fs-sm);
color: var(--text-secondary);
font-family: var(--font-body);
}
.pagination__controls {
display: flex;
align-items: center;
gap: var(--space-1);
}
.pagination__btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
height: 44px;
padding: 0 var(--space-2);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
background-color: var(--bg-surface);
color: var(--text-primary);
font-size: var(--fs-sm);
font-family: var(--font-body);
font-weight: var(--fw-medium);
cursor: pointer;
transition: all var(--transition-fast);
}
.pagination__btn:hover:not(:disabled):not(.pagination__btn--active) {
background-color: var(--color-gray-100);
border-color: var(--border-strong);
}
.pagination__btn:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.pagination__btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination__btn--active {
background-color: var(--color-brand-primary);
border-color: var(--color-brand-primary);
color: var(--color-white);
}
.pagination__btn--active:hover:not(:disabled) {
background-color: var(--color-brand-primary-hover);
border-color: var(--color-brand-primary-hover);
}
.pagination__btn--prev,
.pagination__btn--next {
padding: 0;
}
.pagination__ellipsis {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
height: 44px;
color: var(--text-muted);
font-size: var(--fs-sm);
}

View File

@ -0,0 +1,45 @@
<nav class="pagination" aria-label="Pagination">
<div class="pagination__info">
Showing {{ startItem() }} to {{ endItem() }} of {{ totalItems() }} tasks
</div>
<div class="pagination__controls">
<button
class="pagination__btn pagination__btn--prev"
[disabled]="currentPage() === 1"
(click)="onPrevious()"
aria-label="Previous page"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"/>
</svg>
</button>
@for (page of pages(); track page) {
@if (page === -1) {
<span class="pagination__ellipsis" aria-hidden="true">...</span>
} @else {
<button
class="pagination__btn pagination__btn--page"
[class.pagination__btn--active]="page === currentPage()"
(click)="onPageChange(page)"
[attr.aria-label]="'Page ' + page"
[attr.aria-current]="page === currentPage() ? 'page' : null"
>
{{ page }}
</button>
}
}
<button
class="pagination__btn pagination__btn--next"
[disabled]="currentPage() === totalPages()"
(click)="onNext()"
aria-label="Next page"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"/>
</svg>
</button>
</div>
</nav>

View File

@ -0,0 +1,68 @@
import { ChangeDetectionStrategy, Component, input, output, computed } from '@angular/core';
@Component({
selector: 'emi-pagination',
imports: [],
templateUrl: './pagination.html',
styleUrl: './pagination.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Pagination {
currentPage = input.required<number>();
totalPages = input.required<number>();
totalItems = input.required<number>();
pageSize = input.required<number>();
pageChange = output<number>();
readonly pages = computed(() => {
const result: number[] = [];
const total = this.totalPages();
const current = this.currentPage();
if (total <= 7) {
for (let i = 1; i <= total; i++) {
result.push(i);
}
} else {
result.push(1);
if (current > 3) {
result.push(-1);
}
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
for (let i = start; i <= end; i++) {
result.push(i);
}
if (current < total - 2) {
result.push(-1);
}
result.push(total);
}
return result;
});
readonly startItem = computed(() => (this.currentPage() - 1) * this.pageSize() + 1);
readonly endItem = computed(() => Math.min(this.currentPage() * this.pageSize(), this.totalItems()));
onPageChange(page: number): void {
if (page >= 1 && page <= this.totalPages()) {
this.pageChange.emit(page);
}
}
onPrevious(): void {
this.onPageChange(this.currentPage() - 1);
}
onNext(): void {
this.onPageChange(this.currentPage() + 1);
}
}

View File

@ -0,0 +1,30 @@
.spinner {
display: inline-block;
border: 3px solid var(--color-gray-200);
border-top-color: var(--color-brand-primary);
border-radius: var(--radius-full);
animation: spin 0.8s linear infinite;
}
.spinner--sm {
width: 20px;
height: 20px;
border-width: 2px;
}
.spinner--md {
width: 32px;
height: 32px;
}
.spinner--lg {
width: 48px;
height: 48px;
border-width: 4px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View File

@ -0,0 +1,8 @@
<span
class="spinner"
[class.spinner--sm]="size() === 'sm'"
[class.spinner--md]="size() === 'md'"
[class.spinner--lg]="size() === 'lg'"
role="status"
aria-label="Loading"
></span>

View File

@ -0,0 +1,12 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
@Component({
selector: 'emi-spinner',
imports: [],
templateUrl: './spinner.html',
styleUrl: './spinner.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Spinner {
size = input<'sm' | 'md' | 'lg'>('md');
}

View File

@ -0,0 +1,93 @@
.toast-container {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 24rem;
}
.toast {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
animation: slideIn 0.3s ease-out;
}
.toast--error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
}
.toast--success {
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #166534;
}
.toast--warning {
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
}
.toast--info {
background: #eff6ff;
border: 1px solid #bfdbfe;
color: #1e40af;
}
.toast__content {
display: flex;
align-items: flex-start;
gap: 0.75rem;
flex: 1;
}
.toast__icon {
flex-shrink: 0;
margin-top: 0.125rem;
}
.toast__message {
font-size: 0.875rem;
line-height: 1.25rem;
}
.toast__close {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1.5rem;
height: 1.5rem;
padding: 0;
border: none;
background: transparent;
color: currentColor;
opacity: 0.5;
cursor: pointer;
border-radius: 0.25rem;
transition: opacity 0.15s ease;
}
.toast__close:hover {
opacity: 1;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}

View File

@ -0,0 +1,51 @@
<div class="toast-container" aria-live="polite" aria-label="Notifications">
@for (notification of notifications(); track notification.id) {
<div
class="toast toast--{{ notification.type }}"
role="alert"
>
<div class="toast__content">
@switch (notification.type) {
@case ('error') {
<svg class="toast__icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
}
@case ('success') {
<svg class="toast__icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
}
@case ('warning') {
<svg class="toast__icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
}
@default {
<svg class="toast__icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
}
}
<span class="toast__message">{{ notification.message }}</span>
</div>
<button
class="toast__close"
(click)="onDismiss(notification.id)"
aria-label="Dismiss notification"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
}
</div>

View File

@ -0,0 +1,19 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { Notification } from '@core/services/notification';
@Component({
selector: 'emi-toast',
imports: [],
templateUrl: './toast.html',
styleUrl: './toast.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Toast {
private readonly notification = inject(Notification);
readonly notifications = this.notification.notifications;
onDismiss(id: number): void {
this.notification.dismiss(id);
}
}

View File

@ -0,0 +1,24 @@
export function toISODateString(date: Date): string {
return date.toISOString().split('T')[0];
}
export function isFutureDate(date: Date | string): boolean {
const d = typeof date === 'string' ? new Date(date) : date;
const today = new Date();
today.setHours(0, 0, 0, 0);
return d > today;
}
export function formatRelative(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`;
return `${Math.floor(diffDays / 365)} years ago`;
}

View File

@ -0,0 +1,31 @@
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function futureDateValidator(control: AbstractControl): ValidationErrors | null {
const value = control.value;
if (!value) return null;
const date = new Date(value);
const today = new Date();
today.setHours(0, 0, 0, 0);
return date > today ? null : { futureDate: { value } };
}
export function atLeastOneFilledValidator(...fields: string[]): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const hasValue = fields.some(field => {
const control = group.get(field);
return control && control.value && control.value.trim() !== '';
});
return hasValue ? null : { atLeastOneFilled: { fields } };
};
}
export function trimmedRequiredValidator(control: AbstractControl): ValidationErrors | null {
const value = control.value;
if (typeof value === 'string' && value.trim().length > 0) {
return null;
}
return { trimmedRequired: { value } };
}

View File

@ -0,0 +1,4 @@
export const environment = {
production: false,
apiUrl: 'http://localhost:3000/api',
};

View File

@ -0,0 +1,4 @@
export const environment = {
production: true,
apiUrl: 'https://api.emi.challenge.berand97.dev/api',
};

18
src/index.html Normal file
View File

@ -0,0 +1,18 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>EMI Task Management</title>
<meta name="description" content="EMI Task Management - Gestión de tareas eficiente y profesional">
<meta name="theme-color" content="#6B1F2A">
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
</head>
<body>
<a href="#main" class="skip-link">Saltar al contenido</a>
<emi-root></emi-root>
</body>
</html>

8
src/main.ts Normal file
View File

@ -0,0 +1,8 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err: unknown) => {
console.error(err);
});

253
src/styles.css Normal file
View File

@ -0,0 +1,253 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Sora:wght@600;700;800&display=swap');
:root {
/* Brand Colors */
--color-brand-primary: #E63946;
--color-brand-primary-hover: #C92A3B;
--color-brand-primary-active: #A8202F;
--color-brand-secondary: #6B1F2A;
--color-brand-secondary-dark: #4D1620;
--color-brand-accent: #D32F2F;
/* Support Colors */
--color-success: #25D366;
--color-success-dark: #1EA952;
--color-warning: #F59E0B;
--color-danger: #DC2626;
--color-info: #0EA5E9;
/* Neutrals */
--color-white: #FFFFFF;
--color-gray-50: #F9FAFB;
--color-gray-100: #F3F4F6;
--color-gray-200: #E5E7EB;
--color-gray-300: #D1D5DB;
--color-gray-400: #9CA3AF;
--color-gray-500: #6B7280;
--color-gray-600: #4B5563;
--color-gray-700: #374151;
--color-gray-800: #1F2937;
--color-gray-900: #111827;
/* Semantic Tokens */
--bg-page: var(--color-gray-50);
--bg-surface: var(--color-white);
--bg-elevated: var(--color-white);
--bg-inverse: var(--color-brand-secondary);
--bg-overlay: rgba(17, 24, 39, 0.6);
--text-primary: var(--color-gray-900);
--text-secondary: var(--color-gray-600);
--text-muted: var(--color-gray-400);
--text-inverse: var(--color-white);
--text-link: var(--color-brand-primary);
--text-link-hover: var(--color-brand-primary-hover);
--border-subtle: var(--color-gray-200);
--border-default: var(--color-gray-300);
--border-strong: var(--color-gray-400);
--border-focus: var(--color-brand-primary);
--state-error: var(--color-danger);
--state-success: var(--color-success);
--state-warning: var(--color-warning);
/* Typography */
--font-display: 'Sora', system-ui, sans-serif;
--font-body: 'Inter', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', Menlo, monospace;
--fs-xs: 0.75rem;
--fs-sm: 0.875rem;
--fs-base: 1rem;
--fs-md: 1.125rem;
--fs-lg: clamp(1.25rem, 1rem + 1vw, 1.5rem);
--fs-xl: clamp(1.5rem, 1.2rem + 1.5vw, 2rem);
--fs-2xl: clamp(1.875rem, 1.5rem + 2vw, 2.5rem);
--fs-3xl: clamp(2.25rem, 1.8rem + 3vw, 3.5rem);
--fw-regular: 400;
--fw-medium: 500;
--fw-semibold: 600;
--fw-bold: 700;
--lh-tight: 1.1;
--lh-snug: 1.25;
--lh-normal: 1.5;
--lh-relaxed: 1.65;
--tracking-tight: -0.02em;
--tracking-normal: 0;
--tracking-wide: 0.05em;
/* Spacing */
--space-0: 0;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
--space-16: 4rem;
--space-20: 5rem;
/* Border Radius */
--radius-none: 0;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-2xl: 24px;
--radius-pill: 9999px;
--radius-full: 50%;
/* Shadows */
--shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
--shadow-brand: 0 10px 25px -5px rgba(230, 57, 70, 0.35);
/* Z-index */
--z-base: 0;
--z-dropdown: 100;
--z-sticky: 200;
--z-fixed: 300;
--z-modal-backdrop: 400;
--z-modal: 500;
--z-toast: 800;
/* Transitions */
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
/* Container */
--container-xl: 1200px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-page: #0F0A0B;
--bg-surface: #1A1214;
--bg-elevated: #241719;
--text-primary: #F5F5F5;
--text-secondary: #B8B8B8;
--border-subtle: #2D1F22;
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
font-family: var(--font-body);
font-size: var(--fs-base);
line-height: var(--lh-normal);
color: var(--text-primary);
background-color: var(--bg-page);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-display);
font-weight: var(--fw-bold);
line-height: var(--lh-snug);
letter-spacing: var(--tracking-tight);
color: var(--text-primary);
}
h1 { font-size: var(--fs-3xl); }
h2 { font-size: var(--fs-2xl); }
h3 { font-size: var(--fs-xl); }
h4 { font-size: var(--fs-lg); }
a {
color: var(--text-link);
text-decoration: none;
transition: color var(--transition-fast);
}
a:hover {
color: var(--text-link-hover);
}
:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
img, picture, video, canvas, svg {
display: block;
max-width: 100%;
}
input, button, textarea, select {
font: inherit;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
.container {
width: 100%;
max-width: var(--container-xl);
margin-inline: auto;
padding-inline: var(--space-4);
}
@media (min-width: 768px) {
.container {
padding-inline: var(--space-8);
}
}
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: var(--color-brand-primary);
color: var(--color-white);
padding: var(--space-2) var(--space-4);
z-index: var(--z-toast);
transition: top var(--transition-fast);
}
.skip-link:focus {
top: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}