diff --git a/listify-api/src/app.module.ts b/listify-api/src/app.module.ts index 1a5bbb9..4457d5e 100644 --- a/listify-api/src/app.module.ts +++ b/listify-api/src/app.module.ts @@ -12,6 +12,7 @@ import { ListTemplatesModule } from './list-templates/list-templates.module'; import { ListsModule } from './lists/lists.module'; import { MailModule } from './mail/mail.module'; import { McpModule } from './mcp/mcp.module'; +import { TasksModule } from './tasks/tasks.module'; import { databaseLoggerOptionsFromEnv, parseDatabaseLogging, @@ -64,6 +65,7 @@ import { DatabaseLogger } from './database/database.logger'; MailModule, ListsModule, ListTemplatesModule, + TasksModule, McpModule, ], controllers: [AppController], diff --git a/listify-api/src/audit/audit-log.types.ts b/listify-api/src/audit/audit-log.types.ts index 8413f37..95b7d8c 100644 --- a/listify-api/src/audit/audit-log.types.ts +++ b/listify-api/src/audit/audit-log.types.ts @@ -28,7 +28,12 @@ export type AuditAction = | 'list.item_updated' | 'list.item_checked' | 'list.item_unchecked' - | 'list.item_deleted'; + | 'list.item_deleted' + | 'task.created' + | 'task.updated' + | 'task.completed' + | 'task.reopened' + | 'task.deleted'; export interface AuditLogInput { actorUserId?: string | null; diff --git a/listify-api/src/auth/user.entity.ts b/listify-api/src/auth/user.entity.ts index 1a58243..a857ae5 100644 --- a/listify-api/src/auth/user.entity.ts +++ b/listify-api/src/auth/user.entity.ts @@ -11,6 +11,7 @@ import { ListTemplateEntity } from '../list-templates/list-template.entity'; import { ListTemplateShareEntity } from '../list-templates/list-template-share.entity'; import { UserListEntity } from '../lists/user-list.entity'; import { UserListShareEntity } from '../lists/user-list-share.entity'; +import { UserTaskEntity } from '../tasks/user-task.entity'; import { RefreshTokenEntity } from './refresh-token.entity'; @Entity('users') @@ -72,6 +73,9 @@ export class UserEntity { @OneToMany(() => UserListShareEntity, (share) => share.user) sharedLists?: UserListShareEntity[]; + @OneToMany(() => UserTaskEntity, (task) => task.owner) + tasks?: UserTaskEntity[]; + @OneToMany(() => ListTemplateShareEntity, (share) => share.user) sharedTemplates?: ListTemplateShareEntity[]; } diff --git a/listify-api/src/database/data-source.ts b/listify-api/src/database/data-source.ts index 100d582..9bfa280 100644 --- a/listify-api/src/database/data-source.ts +++ b/listify-api/src/database/data-source.ts @@ -13,6 +13,7 @@ import { ListTemplateShareEntity } from '../list-templates/list-template-share.e import { TemplateSeedEntity } from '../list-templates/template-seed.entity'; import { UserListEntity } from '../lists/user-list.entity'; import { UserListItemEntity } from '../lists/user-list-item.entity'; +import { UserTaskEntity } from '../tasks/user-task.entity'; import { databaseLoggerOptionsFromEnv, parseDatabaseLogging, @@ -43,6 +44,7 @@ export default new DataSource({ TemplateSeedEntity, UserListEntity, UserListItemEntity, + UserTaskEntity, WeeklyListSuggestionSnapshotEntity, ], migrations: ['src/database/migrations/*.ts'], diff --git a/listify-api/src/database/migrations/1781900000000-CreateUserTasks.ts b/listify-api/src/database/migrations/1781900000000-CreateUserTasks.ts new file mode 100644 index 0000000..2d2e7e4 --- /dev/null +++ b/listify-api/src/database/migrations/1781900000000-CreateUserTasks.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateUserTasks1781900000000 implements MigrationInterface { + name = 'CreateUserTasks1781900000000'; + + public async up(queryRunner: QueryRunner): Promise { + if (!(await queryRunner.hasTable('user_tasks'))) { + await queryRunner.query(` + CREATE TABLE \`user_tasks\` ( + \`id\` varchar(36) NOT NULL, + \`ownerId\` varchar(36) NOT NULL, + \`title\` varchar(220) NOT NULL, + \`notes\` text NULL, + \`dueDate\` varchar(10) NOT NULL, + \`completed\` tinyint NOT NULL DEFAULT 0, + \`completedAt\` datetime(3) NULL, + \`createdAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + \`updatedAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + \`deletedAt\` datetime(3) NULL, + INDEX \`IDX_user_tasks_owner_id\` (\`ownerId\`), + INDEX \`IDX_user_tasks_owner_due_date\` (\`ownerId\`, \`dueDate\`), + INDEX \`IDX_user_tasks_deleted_at\` (\`deletedAt\`), + PRIMARY KEY (\`id\`) + ) ENGINE=InnoDB + `); + await queryRunner.query(` + ALTER TABLE \`user_tasks\` + ADD CONSTRAINT \`FK_user_tasks_owner_id\` + FOREIGN KEY (\`ownerId\`) REFERENCES \`users\`(\`id\`) + ON DELETE CASCADE ON UPDATE NO ACTION + `); + } + } + + public async down(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasTable('user_tasks')) { + await queryRunner.query( + 'ALTER TABLE `user_tasks` DROP FOREIGN KEY `FK_user_tasks_owner_id`', + ); + await queryRunner.query('DROP TABLE `user_tasks`'); + } + } +} diff --git a/listify-api/src/tasks/dto/create-task.dto.ts b/listify-api/src/tasks/dto/create-task.dto.ts new file mode 100644 index 0000000..680a37c --- /dev/null +++ b/listify-api/src/tasks/dto/create-task.dto.ts @@ -0,0 +1,5 @@ +export class CreateTaskDto { + title?: string; + notes?: string; + dueDate?: string; +} diff --git a/listify-api/src/tasks/dto/update-task.dto.ts b/listify-api/src/tasks/dto/update-task.dto.ts new file mode 100644 index 0000000..19fc793 --- /dev/null +++ b/listify-api/src/tasks/dto/update-task.dto.ts @@ -0,0 +1,6 @@ +export class UpdateTaskDto { + title?: string; + notes?: string | null; + dueDate?: string; + completed?: boolean; +} diff --git a/listify-api/src/tasks/tasks.controller.ts b/listify-api/src/tasks/tasks.controller.ts new file mode 100644 index 0000000..79bce55 --- /dev/null +++ b/listify-api/src/tasks/tasks.controller.ts @@ -0,0 +1,77 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + Req, + UnauthorizedException, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CreateTaskDto } from './dto/create-task.dto'; +import { UpdateTaskDto } from './dto/update-task.dto'; +import { TasksService } from './tasks.service'; +import type { AuthenticatedRequest } from '../auth/auth.types'; + +@Controller('tasks') +@UseGuards(JwtAuthGuard) +export class TasksController { + constructor(private readonly tasksService: TasksService) {} + + @Post() + createTask( + @Req() request: AuthenticatedRequest, + @Body() createDto: CreateTaskDto, + ) { + return this.tasksService.createTask(this.requireUserId(request), createDto); + } + + @Get() + listTasks( + @Req() request: AuthenticatedRequest, + @Query('date') date?: string, + ) { + return this.tasksService.listTasks(this.requireUserId(request), date); + } + + @Get(':taskId') + getTask( + @Req() request: AuthenticatedRequest, + @Param('taskId') taskId: string, + ) { + return this.tasksService.getTask(this.requireUserId(request), taskId); + } + + @Patch(':taskId') + updateTask( + @Req() request: AuthenticatedRequest, + @Param('taskId') taskId: string, + @Body() updateDto: UpdateTaskDto, + ) { + return this.tasksService.updateTask( + this.requireUserId(request), + taskId, + updateDto, + ); + } + + @Delete(':taskId') + deleteTask( + @Req() request: AuthenticatedRequest, + @Param('taskId') taskId: string, + ) { + return this.tasksService.deleteTask(this.requireUserId(request), taskId); + } + + private requireUserId(request: AuthenticatedRequest): string { + if (!request.user?.sub) { + throw new UnauthorizedException('Authenticated user is required.'); + } + + return request.user.sub; + } +} diff --git a/listify-api/src/tasks/tasks.module.ts b/listify-api/src/tasks/tasks.module.ts new file mode 100644 index 0000000..ed586d3 --- /dev/null +++ b/listify-api/src/tasks/tasks.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuditModule } from '../audit/audit.module'; +import { TasksController } from './tasks.controller'; +import { TasksService } from './tasks.service'; +import { UserTaskEntity } from './user-task.entity'; + +@Module({ + imports: [AuditModule, TypeOrmModule.forFeature([UserTaskEntity])], + controllers: [TasksController], + providers: [TasksService], + exports: [TasksService], +}) +export class TasksModule {} diff --git a/listify-api/src/tasks/tasks.service.spec.ts b/listify-api/src/tasks/tasks.service.spec.ts new file mode 100644 index 0000000..7f4ab99 --- /dev/null +++ b/listify-api/src/tasks/tasks.service.spec.ts @@ -0,0 +1,87 @@ +import { + BadRequestException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { InMemoryRepository } from '../testing/in-memory-repository'; +import { TasksService } from './tasks.service'; +import { UserTaskEntity } from './user-task.entity'; + +describe('TasksService', () => { + let service: TasksService; + let tasksRepository: InMemoryRepository; + + beforeEach(() => { + tasksRepository = new InMemoryRepository(); + service = new TasksService(tasksRepository as never); + }); + + it('creates and lists day tasks for the owning user', async () => { + const task = await service.createTask('user-1', { + title: 'Arzttermin buchen', + notes: 'Vormittags anrufen', + dueDate: '2026-06-29', + }); + + expect(task.title).toBe('Arzttermin buchen'); + expect(task.notes).toBe('Vormittags anrufen'); + expect(task.dueDate).toBe('2026-06-29'); + expect(task.completed).toBe(false); + expect(task.completedAt).toBeUndefined(); + await expect(service.listTasks('user-1', '2026-06-29')).resolves.toEqual([ + task, + ]); + await expect(service.listTasks('user-2', '2026-06-29')).resolves.toEqual( + [], + ); + }); + + it('updates, completes, reopens and deletes tasks', async () => { + const task = await service.createTask('user-1', { + title: 'Rechnung zahlen', + dueDate: '2026-06-29', + }); + const updatedTask = await service.updateTask('user-1', task.id, { + title: 'Stromrechnung zahlen', + dueDate: '2026-06-30', + completed: true, + }); + const reopenedTask = await service.updateTask('user-1', task.id, { + completed: false, + }); + const deleteResponse = await service.deleteTask('user-1', task.id); + + expect(updatedTask.title).toBe('Stromrechnung zahlen'); + expect(updatedTask.dueDate).toBe('2026-06-30'); + expect(updatedTask.completed).toBe(true); + expect(updatedTask.completedAt).toBeDefined(); + expect(reopenedTask.completed).toBe(false); + expect(reopenedTask.completedAt).toBeUndefined(); + expect(deleteResponse.message).toBe('Task deleted.'); + await expect(service.listTasks('user-1')).resolves.toEqual([]); + await expect(service.getTask('user-1', task.id)).rejects.toThrow( + NotFoundException, + ); + }); + + it('rejects invalid input and blocks foreign access', async () => { + await expect( + service.createTask('user-1', { title: ' ', dueDate: '2026-06-29' }), + ).rejects.toThrow(BadRequestException); + await expect( + service.createTask('user-1', { title: 'Task', dueDate: '2026-02-31' }), + ).rejects.toThrow('Task due date must be a valid date.'); + + const task = await service.createTask('user-1', { + title: 'Privat', + dueDate: '2026-06-29', + }); + + await expect(service.getTask('user-2', task.id)).rejects.toThrow( + ForbiddenException, + ); + await expect(service.getTask('user-1', 'missing')).rejects.toThrow( + NotFoundException, + ); + }); +}); diff --git a/listify-api/src/tasks/tasks.service.ts b/listify-api/src/tasks/tasks.service.ts new file mode 100644 index 0000000..c6b4d23 --- /dev/null +++ b/listify-api/src/tasks/tasks.service.ts @@ -0,0 +1,250 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, + Optional, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { randomUUID } from 'crypto'; +import { IsNull, Repository } from 'typeorm'; +import { AuditLogService } from '../audit/audit-log.service'; +import { CreateTaskDto } from './dto/create-task.dto'; +import { UpdateTaskDto } from './dto/update-task.dto'; +import { UserTask } from './tasks.types'; +import { UserTaskEntity } from './user-task.entity'; + +@Injectable() +export class TasksService { + constructor( + @InjectRepository(UserTaskEntity) + private readonly tasksRepository: Repository, + @Optional() + private readonly auditLogService?: AuditLogService, + ) {} + + async createTask( + ownerId: string, + createDto: CreateTaskDto, + ): Promise { + const task = this.tasksRepository.create({ + id: randomUUID(), + ownerId, + title: this.requireTitle(createDto.title), + notes: this.normalizeOptionalText(createDto.notes), + dueDate: this.normalizeDueDate(createDto.dueDate ?? this.todayKey()), + completed: false, + completedAt: null, + deletedAt: null, + }); + const savedTask = await this.tasksRepository.save(task); + + await this.auditLogService?.record({ + actorUserId: ownerId, + action: 'task.created', + entityType: 'task', + entityId: savedTask.id, + metadata: { + title: savedTask.title, + dueDate: savedTask.dueDate, + }, + }); + + return this.toUserTask(savedTask); + } + + async listTasks(ownerId: string, dueDate?: string): Promise { + const normalizedDueDate = + dueDate !== undefined ? this.normalizeDueDate(dueDate) : undefined; + const tasks = await this.tasksRepository.find({ + where: { + ownerId, + deletedAt: IsNull(), + ...(normalizedDueDate ? { dueDate: normalizedDueDate } : {}), + }, + order: { dueDate: 'ASC', completed: 'ASC', createdAt: 'ASC' }, + }); + + return tasks.map((task) => this.toUserTask(task)); + } + + async getTask(ownerId: string, taskId: string): Promise { + return this.toUserTask(await this.findOwnedTask(ownerId, taskId)); + } + + async updateTask( + ownerId: string, + taskId: string, + updateDto: UpdateTaskDto, + ): Promise { + const task = await this.findOwnedTask(ownerId, taskId); + const wasCompleted = task.completed; + + if (updateDto.title !== undefined) { + task.title = this.requireTitle(updateDto.title); + } + + if (updateDto.notes !== undefined) { + task.notes = this.normalizeOptionalText(updateDto.notes ?? undefined); + } + + if (updateDto.dueDate !== undefined) { + task.dueDate = this.normalizeDueDate(updateDto.dueDate); + } + + if (updateDto.completed !== undefined) { + task.completed = this.normalizeBoolean(updateDto.completed, 'completed'); + task.completedAt = task.completed ? new Date() : null; + } + + const savedTask = await this.tasksRepository.save(task); + + await this.auditLogService?.record({ + actorUserId: ownerId, + action: + updateDto.completed === true + ? 'task.completed' + : updateDto.completed === false + ? 'task.reopened' + : 'task.updated', + entityType: 'task', + entityId: savedTask.id, + metadata: { + title: savedTask.title, + dueDate: savedTask.dueDate, + previousCompleted: wasCompleted, + completed: savedTask.completed, + changedFields: Object.keys(updateDto).filter( + (field) => updateDto[field as keyof UpdateTaskDto] !== undefined, + ), + }, + }); + + return this.toUserTask(savedTask); + } + + async deleteTask( + ownerId: string, + taskId: string, + ): Promise<{ message: string }> { + const task = await this.findOwnedTask(ownerId, taskId); + + task.deletedAt = new Date(); + await this.tasksRepository.save(task); + + await this.auditLogService?.record({ + actorUserId: ownerId, + action: 'task.deleted', + entityType: 'task', + entityId: taskId, + metadata: { + title: task.title, + dueDate: task.dueDate, + completed: task.completed, + }, + }); + + return { message: 'Task deleted.' }; + } + + private async findOwnedTask( + ownerId: string, + taskId: string, + ): Promise { + const task = await this.tasksRepository.findOne({ + where: { id: taskId, deletedAt: IsNull() }, + }); + + if (!task) { + throw new NotFoundException('Task was not found.'); + } + + if (task.ownerId !== ownerId) { + throw new ForbiddenException('You do not have access to this task.'); + } + + return task; + } + + private requireTitle(value: string | undefined): string { + const title = this.compactText(value, 220); + + if (!title) { + throw new BadRequestException('Task title is required.'); + } + + return title; + } + + private normalizeOptionalText(value: string | undefined): string | null { + return this.compactText(value, 1000) ?? null; + } + + private normalizeDueDate(value: string): string { + const candidate = value.trim(); + + if (!/^\d{4}-\d{2}-\d{2}$/.test(candidate)) { + throw new BadRequestException('Task due date must be YYYY-MM-DD.'); + } + + const parsedDate = new Date(`${candidate}T00:00:00.000Z`); + + if ( + Number.isNaN(parsedDate.getTime()) || + parsedDate.toISOString().slice(0, 10) !== candidate + ) { + throw new BadRequestException('Task due date must be a valid date.'); + } + + return candidate; + } + + private normalizeBoolean(value: boolean, fieldName: string): boolean { + if (typeof value !== 'boolean') { + throw new BadRequestException(`${fieldName} must be a boolean.`); + } + + return value; + } + + private compactText( + value: string | undefined, + maxLength: number, + ): string | undefined { + const compacted = value?.replace(/\s+/g, ' ').trim(); + + if (!compacted) { + return undefined; + } + + if (compacted.length <= maxLength) { + return compacted; + } + + return `${compacted.slice(0, maxLength - 3)}...`; + } + + private todayKey(): string { + return new Date().toISOString().slice(0, 10); + } + + private toUserTask(task: UserTaskEntity): UserTask { + return { + id: task.id, + ownerId: task.ownerId, + title: task.title, + notes: task.notes ?? undefined, + dueDate: task.dueDate, + completed: task.completed, + completedAt: task.completedAt + ? this.toIsoString(task.completedAt) + : undefined, + createdAt: this.toIsoString(task.createdAt), + updatedAt: this.toIsoString(task.updatedAt), + }; + } + + private toIsoString(value?: Date): string { + return (value ?? new Date()).toISOString(); + } +} diff --git a/listify-api/src/tasks/tasks.types.ts b/listify-api/src/tasks/tasks.types.ts new file mode 100644 index 0000000..717e481 --- /dev/null +++ b/listify-api/src/tasks/tasks.types.ts @@ -0,0 +1,11 @@ +export interface UserTask { + id: string; + ownerId: string; + title: string; + notes?: string; + dueDate: string; + completed: boolean; + completedAt?: string; + createdAt: string; + updatedAt: string; +} diff --git a/listify-api/src/tasks/user-task.entity.ts b/listify-api/src/tasks/user-task.entity.ts new file mode 100644 index 0000000..4c713d4 --- /dev/null +++ b/listify-api/src/tasks/user-task.entity.ts @@ -0,0 +1,62 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import { UserEntity } from '../auth/user.entity'; + +@Entity('user_tasks') +@Index('IDX_user_tasks_owner_due_date', ['ownerId', 'dueDate']) +export class UserTaskEntity { + @PrimaryColumn({ type: 'varchar', length: 36 }) + id!: string; + + @Index() + @Column({ type: 'varchar', length: 36 }) + ownerId!: string; + + @Column({ type: 'varchar', length: 220 }) + title!: string; + + @Column({ type: 'text', nullable: true }) + notes?: string | null; + + @Column({ type: 'varchar', length: 10 }) + dueDate!: string; + + @Column({ type: 'boolean', default: false }) + completed!: boolean; + + @Column({ type: 'datetime', precision: 3, nullable: true }) + completedAt?: Date | null; + + @CreateDateColumn({ + type: 'datetime', + precision: 3, + default: () => 'CURRENT_TIMESTAMP(3)', + }) + createdAt!: Date; + + @UpdateDateColumn({ + type: 'datetime', + precision: 3, + default: () => 'CURRENT_TIMESTAMP(3)', + onUpdate: 'CURRENT_TIMESTAMP(3)', + }) + updatedAt!: Date; + + @Index('IDX_user_tasks_deleted_at') + @Column({ type: 'datetime', precision: 3, nullable: true }) + deletedAt?: Date | null; + + @ManyToOne(() => UserEntity, (user) => user.tasks, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'ownerId' }) + owner?: UserEntity; +} diff --git a/listify-api/src/testing/in-memory-repository.ts b/listify-api/src/testing/in-memory-repository.ts index ba0fe60..1f94786 100644 --- a/listify-api/src/testing/in-memory-repository.ts +++ b/listify-api/src/testing/in-memory-repository.ts @@ -116,6 +116,9 @@ export class InMemoryRepository { const sortedRecords = [...records]; const typedOrder = order as { name?: 'ASC' | 'DESC'; + createdAt?: 'ASC' | 'DESC'; + dueDate?: 'ASC' | 'DESC'; + completed?: 'ASC' | 'DESC'; reminderAt?: 'ASC' | 'DESC'; items?: { position?: 'ASC' | 'DESC' }; }; @@ -142,6 +145,45 @@ export class InMemoryRepository { }); } + if (typedOrder?.dueDate) { + sortedRecords.sort((left, right) => { + const leftDate = String( + (left as Record)['dueDate'] ?? '', + ); + const rightDate = String( + (right as Record)['dueDate'] ?? '', + ); + return typedOrder.dueDate === 'DESC' + ? rightDate.localeCompare(leftDate) + : leftDate.localeCompare(rightDate); + }); + } + + if (typedOrder?.completed) { + sortedRecords.sort((left, right) => { + const leftCompleted = Boolean( + (left as Record)['completed'], + ); + const rightCompleted = Boolean( + (right as Record)['completed'], + ); + const comparison = Number(leftCompleted) - Number(rightCompleted); + return typedOrder.completed === 'DESC' ? -comparison : comparison; + }); + } + + if (typedOrder?.createdAt) { + sortedRecords.sort((left, right) => { + const leftDate = (left as Record)['createdAt']; + const rightDate = (right as Record)['createdAt']; + const leftTime = leftDate instanceof Date ? leftDate.getTime() : 0; + const rightTime = rightDate instanceof Date ? rightDate.getTime() : 0; + return typedOrder.createdAt === 'DESC' + ? rightTime - leftTime + : leftTime - rightTime; + }); + } + if (typedOrder?.items?.position) { for (const record of sortedRecords) { const items = (record as { items?: Array<{ position: number }> }).items; diff --git a/listify-client/src/app/app.html b/listify-client/src/app/app.html index 5fe3999..4683910 100644 --- a/listify-client/src/app/app.html +++ b/listify-client/src/app/app.html @@ -84,6 +84,16 @@ Templates + + + Tasks + + + + Tasks + module.DashboardComponent), canActivate: [authGuard], }, + { + path: 'tasks', + loadComponent: () => import('./tasks/tasks.component').then((module) => module.TasksComponent), + canActivate: [authGuard], + }, + { path: 'aufgaben', redirectTo: 'tasks' }, { path: 'templates', component: TemplatesComponent, canActivate: [authGuard] }, { path: 'templates/new', diff --git a/listify-client/src/app/app.scss b/listify-client/src/app/app.scss index a12d6fe..af14924 100644 --- a/listify-client/src/app/app.scss +++ b/listify-client/src/app/app.scss @@ -148,7 +148,7 @@ left: 0; z-index: 20; display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0.25rem; padding: 0.35rem 0.5rem calc(0.35rem + env(safe-area-inset-bottom)); border-top: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent); diff --git a/listify-client/src/app/tasks/tasks.component.html b/listify-client/src/app/tasks/tasks.component.html new file mode 100644 index 0000000..9b95303 --- /dev/null +++ b/listify-client/src/app/tasks/tasks.component.html @@ -0,0 +1,202 @@ +
+ + + + + + Neuer Task + + + + + Notiz + + + + + + + + @if (loading()) { + + + +

Tasks werden geladen

+
+
+ } @else if (errorMessage()) { + + + +

Tasks konnten nicht geladen werden

+

{{ errorMessage() }}

+ +
+
+ } @else if (hasTasks()) { +
+ + + {{ openTasks().length }} offen + + + + {{ completedTasks().length }} erledigt + +
+ +
+ @for (task of tasks(); track task.id) { + + + + + @if (editingTaskId() === task.id) { +
+ + Titel + + + + Notiz + + + + Tag + + +
+ + +
+
+ } @else { +
+

{{ task.title }}

+ @if (task.notes) { +

{{ task.notes }}

+ } + + + {{ formatDay(task.dueDate) }} + +
+ +
+ + +
+ } +
+
+ } +
+ } @else { + + + +

Keine Tasks fuer diesen Tag

+

Lege oben eine Aufgabe an, die an diesem Tag erledigt werden soll.

+
+
+ } +
diff --git a/listify-client/src/app/tasks/tasks.component.scss b/listify-client/src/app/tasks/tasks.component.scss new file mode 100644 index 0000000..758ac7d --- /dev/null +++ b/listify-client/src/app/tasks/tasks.component.scss @@ -0,0 +1,164 @@ +.day-controls { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + align-items: center; + gap: 0.35rem; + width: 100%; + min-width: 0; +} + +.day-controls mat-form-field { + min-width: 0; + width: 100%; +} + +.task-create-card { + margin-bottom: 1rem; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent); + border-radius: 8px; + background: var(--mat-sys-surface); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); +} + +.task-create-card mat-card-content { + display: grid; + gap: 0.75rem; + min-width: 0; + padding: 1rem; +} + +.task-create-card mat-form-field, +.task-edit-form mat-form-field { + min-width: 0; + width: 100%; +} + +.task-create-card button { + justify-self: start; +} + +.task-summary { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin: 0 0 0.85rem; + color: var(--mat-sys-on-surface-variant); +} + +.task-summary span, +.task-content span { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.task-summary mat-icon, +.task-content mat-icon { + width: 18px; + height: 18px; + font-size: 18px; +} + +.task-list { + display: grid; + gap: 0.75rem; + min-width: 0; +} + +.task-card { + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent); + border-radius: 8px; + background: var(--mat-sys-surface); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); +} + +.task-card.completed { + background: color-mix(in srgb, var(--mat-sys-surface-container) 72%, var(--mat-sys-surface)); +} + +.task-card mat-card-content { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: start; + gap: 0.75rem; + padding: 0.85rem 1rem; +} + +.task-content { + min-width: 0; +} + +.task-content h2 { + margin: 0; + overflow-wrap: anywhere; + font-size: 1rem; + font-weight: 600; +} + +.task-card.completed .task-content h2 { + color: var(--mat-sys-on-surface-variant); + text-decoration: line-through; +} + +.task-content p { + margin: 0.3rem 0 0; + color: var(--mat-sys-on-surface-variant); + overflow-wrap: anywhere; +} + +.task-content span { + margin-top: 0.45rem; + color: var(--mat-sys-on-surface-variant); + font-size: 0.85rem; +} + +.task-actions { + display: flex; + flex: 0 0 auto; + gap: 0.15rem; +} + +.task-edit-form { + display: grid; + grid-column: 2 / -1; + gap: 0.7rem; + min-width: 0; +} + +.task-edit-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +@media (max-width: 520px) { + .day-controls { + grid-template-columns: auto minmax(0, 1fr) auto; + } + + .day-controls button[mat-stroked-button] { + grid-column: 1 / -1; + justify-self: start; + } + + .task-card mat-card-content { + grid-template-columns: auto minmax(0, 1fr); + } + + .task-actions { + grid-column: 2; + } +} + +@media (min-width: 701px) { + .day-controls { + width: min(100%, 440px); + } + + .task-create-card mat-card-content { + grid-template-columns: minmax(220px, 1.2fr) minmax(180px, 1fr) auto; + align-items: start; + } +} diff --git a/listify-client/src/app/tasks/tasks.component.ts b/listify-client/src/app/tasks/tasks.component.ts new file mode 100644 index 0000000..8b05319 --- /dev/null +++ b/listify-client/src/app/tasks/tasks.component.ts @@ -0,0 +1,263 @@ +import { Component, OnInit, computed, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { finalize } from 'rxjs'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { getAuthErrorMessage } from '../auth/error-message'; +import { TasksService } from './tasks.service'; +import { UserTask } from './tasks.models'; + +@Component({ + selector: 'app-tasks', + imports: [ + FormsModule, + MatButtonModule, + MatCardModule, + MatCheckboxModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatProgressSpinnerModule, + MatSnackBarModule, + ], + templateUrl: './tasks.component.html', + styleUrls: ['../workspace-page.scss', './tasks.component.scss'], +}) +export class TasksComponent implements OnInit { + private readonly snackBar = inject(MatSnackBar); + private readonly tasksService = inject(TasksService); + + protected readonly tasks = signal([]); + protected readonly loading = signal(true); + protected readonly saving = signal(false); + protected readonly updatingTaskId = signal(null); + protected readonly deletingTaskId = signal(null); + protected readonly errorMessage = signal(null); + protected readonly selectedDate = signal(this.todayKey()); + protected readonly newTitle = signal(''); + protected readonly newNotes = signal(''); + protected readonly editingTaskId = signal(null); + protected readonly editTitle = signal(''); + protected readonly editNotes = signal(''); + protected readonly editDueDate = signal(this.todayKey()); + protected readonly openTasks = computed(() => this.tasks().filter((task) => !task.completed)); + protected readonly completedTasks = computed(() => this.tasks().filter((task) => task.completed)); + protected readonly hasTasks = computed(() => this.tasks().length > 0); + + ngOnInit(): void { + this.loadTasks(); + } + + protected loadTasks(): void { + this.loading.set(true); + this.errorMessage.set(null); + + this.tasksService.listTasks(this.selectedDate()).subscribe({ + next: (tasks) => { + this.tasks.set(this.sortTasks(tasks)); + this.loading.set(false); + }, + error: (error: unknown) => { + this.errorMessage.set(getAuthErrorMessage(error)); + this.loading.set(false); + }, + }); + } + + protected createTask(): void { + const title = this.newTitle().trim(); + + if (!title || this.saving()) { + return; + } + + this.saving.set(true); + this.tasksService + .createTask({ + title, + notes: this.optionalText(this.newNotes()), + dueDate: this.selectedDate(), + }) + .pipe(finalize(() => this.saving.set(false))) + .subscribe({ + next: (task) => { + this.tasks.update((tasks) => this.sortTasks([...tasks, task])); + this.newTitle.set(''); + this.newNotes.set(''); + }, + error: (error: unknown) => { + this.snackBar.open(getAuthErrorMessage(error), 'OK', { + duration: 5000, + }); + }, + }); + } + + protected toggleTask(task: UserTask, completed: boolean): void { + if (this.updatingTaskId()) { + return; + } + + this.updatingTaskId.set(task.id); + this.tasksService + .updateTask(task.id, { completed }) + .pipe(finalize(() => this.updatingTaskId.set(null))) + .subscribe({ + next: (updatedTask) => this.replaceTask(updatedTask), + error: (error: unknown) => { + this.snackBar.open(getAuthErrorMessage(error), 'OK', { + duration: 5000, + }); + }, + }); + } + + protected startEdit(task: UserTask): void { + this.editingTaskId.set(task.id); + this.editTitle.set(task.title); + this.editNotes.set(task.notes ?? ''); + this.editDueDate.set(task.dueDate); + } + + protected cancelEdit(): void { + this.editingTaskId.set(null); + this.editTitle.set(''); + this.editNotes.set(''); + this.editDueDate.set(this.selectedDate()); + } + + protected saveEdit(task: UserTask): void { + const title = this.editTitle().trim(); + + if (!title || this.updatingTaskId()) { + return; + } + + this.updatingTaskId.set(task.id); + this.tasksService + .updateTask(task.id, { + title, + notes: this.optionalText(this.editNotes()) ?? null, + dueDate: this.editDueDate(), + }) + .pipe(finalize(() => this.updatingTaskId.set(null))) + .subscribe({ + next: (updatedTask) => { + if (updatedTask.dueDate !== this.selectedDate()) { + this.tasks.update((tasks) => + tasks.filter((existingTask) => existingTask.id !== updatedTask.id), + ); + } else { + this.replaceTask(updatedTask); + } + this.cancelEdit(); + }, + error: (error: unknown) => { + this.snackBar.open(getAuthErrorMessage(error), 'OK', { + duration: 5000, + }); + }, + }); + } + + protected deleteTask(task: UserTask): void { + if (this.deletingTaskId()) { + return; + } + + this.deletingTaskId.set(task.id); + this.tasksService + .deleteTask(task.id) + .pipe(finalize(() => this.deletingTaskId.set(null))) + .subscribe({ + next: () => { + this.tasks.update((tasks) => tasks.filter((existingTask) => existingTask.id !== task.id)); + }, + error: (error: unknown) => { + this.snackBar.open(getAuthErrorMessage(error), 'OK', { + duration: 5000, + }); + }, + }); + } + + protected previousDay(): void { + this.shiftSelectedDate(-1); + } + + protected nextDay(): void { + this.shiftSelectedDate(1); + } + + protected selectToday(): void { + this.selectedDate.set(this.todayKey()); + this.cancelEdit(); + this.loadTasks(); + } + + protected selectDate(value: string): void { + if (!value || value === this.selectedDate()) { + return; + } + + this.selectedDate.set(value); + this.cancelEdit(); + this.loadTasks(); + } + + protected formatDay(value: string): string { + const [year, month, day] = value.split('-'); + + return year && month && day ? `${day}.${month}.${year}` : value; + } + + private shiftSelectedDate(offsetDays: number): void { + const date = new Date(`${this.selectedDate()}T00:00:00`); + + date.setDate(date.getDate() + offsetDays); + this.selectedDate.set(this.toDateInputValue(date)); + this.cancelEdit(); + this.loadTasks(); + } + + private replaceTask(task: UserTask): void { + this.tasks.update((tasks) => + this.sortTasks( + tasks.map((existingTask) => (existingTask.id === task.id ? task : existingTask)), + ), + ); + } + + private sortTasks(tasks: UserTask[]): UserTask[] { + return [...tasks].sort((left, right) => { + if (left.completed !== right.completed) { + return left.completed ? 1 : -1; + } + + return new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime(); + }); + } + + private optionalText(value: string): string | undefined { + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; + } + + private todayKey(): string { + return this.toDateInputValue(new Date()); + } + + private toDateInputValue(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + + return `${year}-${month}-${day}`; + } +} diff --git a/listify-client/src/app/tasks/tasks.models.ts b/listify-client/src/app/tasks/tasks.models.ts new file mode 100644 index 0000000..77c16d2 --- /dev/null +++ b/listify-client/src/app/tasks/tasks.models.ts @@ -0,0 +1,24 @@ +export interface UserTask { + id: string; + ownerId: string; + title: string; + notes?: string; + dueDate: string; + completed: boolean; + completedAt?: string; + createdAt: string; + updatedAt: string; +} + +export interface CreateTaskRequest { + title: string; + notes?: string; + dueDate: string; +} + +export interface UpdateTaskRequest { + title?: string; + notes?: string | null; + dueDate?: string; + completed?: boolean; +} diff --git a/listify-client/src/app/tasks/tasks.service.ts b/listify-client/src/app/tasks/tasks.service.ts new file mode 100644 index 0000000..cace0dd --- /dev/null +++ b/listify-client/src/app/tasks/tasks.service.ts @@ -0,0 +1,28 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { CreateTaskRequest, UpdateTaskRequest, UserTask } from './tasks.models'; + +@Injectable({ providedIn: 'root' }) +export class TasksService { + private readonly http = inject(HttpClient); + private readonly apiUrl = '/api/tasks'; + + listTasks(date?: string): Observable { + const params = date ? new HttpParams().set('date', date) : undefined; + + return this.http.get(this.apiUrl, { params }); + } + + createTask(data: CreateTaskRequest): Observable { + return this.http.post(this.apiUrl, data); + } + + updateTask(taskId: string, data: UpdateTaskRequest): Observable { + return this.http.patch(`${this.apiUrl}/${taskId}`, data); + } + + deleteTask(taskId: string): Observable<{ message: string }> { + return this.http.delete<{ message: string }>(`${this.apiUrl}/${taskId}`); + } +}