tasks
This commit is contained in:
@@ -12,6 +12,7 @@ import { ListTemplatesModule } from './list-templates/list-templates.module';
|
|||||||
import { ListsModule } from './lists/lists.module';
|
import { ListsModule } from './lists/lists.module';
|
||||||
import { MailModule } from './mail/mail.module';
|
import { MailModule } from './mail/mail.module';
|
||||||
import { McpModule } from './mcp/mcp.module';
|
import { McpModule } from './mcp/mcp.module';
|
||||||
|
import { TasksModule } from './tasks/tasks.module';
|
||||||
import {
|
import {
|
||||||
databaseLoggerOptionsFromEnv,
|
databaseLoggerOptionsFromEnv,
|
||||||
parseDatabaseLogging,
|
parseDatabaseLogging,
|
||||||
@@ -64,6 +65,7 @@ import { DatabaseLogger } from './database/database.logger';
|
|||||||
MailModule,
|
MailModule,
|
||||||
ListsModule,
|
ListsModule,
|
||||||
ListTemplatesModule,
|
ListTemplatesModule,
|
||||||
|
TasksModule,
|
||||||
McpModule,
|
McpModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
|
|||||||
@@ -28,7 +28,12 @@ export type AuditAction =
|
|||||||
| 'list.item_updated'
|
| 'list.item_updated'
|
||||||
| 'list.item_checked'
|
| 'list.item_checked'
|
||||||
| 'list.item_unchecked'
|
| 'list.item_unchecked'
|
||||||
| 'list.item_deleted';
|
| 'list.item_deleted'
|
||||||
|
| 'task.created'
|
||||||
|
| 'task.updated'
|
||||||
|
| 'task.completed'
|
||||||
|
| 'task.reopened'
|
||||||
|
| 'task.deleted';
|
||||||
|
|
||||||
export interface AuditLogInput {
|
export interface AuditLogInput {
|
||||||
actorUserId?: string | null;
|
actorUserId?: string | null;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { ListTemplateEntity } from '../list-templates/list-template.entity';
|
|||||||
import { ListTemplateShareEntity } from '../list-templates/list-template-share.entity';
|
import { ListTemplateShareEntity } from '../list-templates/list-template-share.entity';
|
||||||
import { UserListEntity } from '../lists/user-list.entity';
|
import { UserListEntity } from '../lists/user-list.entity';
|
||||||
import { UserListShareEntity } from '../lists/user-list-share.entity';
|
import { UserListShareEntity } from '../lists/user-list-share.entity';
|
||||||
|
import { UserTaskEntity } from '../tasks/user-task.entity';
|
||||||
import { RefreshTokenEntity } from './refresh-token.entity';
|
import { RefreshTokenEntity } from './refresh-token.entity';
|
||||||
|
|
||||||
@Entity('users')
|
@Entity('users')
|
||||||
@@ -72,6 +73,9 @@ export class UserEntity {
|
|||||||
@OneToMany(() => UserListShareEntity, (share) => share.user)
|
@OneToMany(() => UserListShareEntity, (share) => share.user)
|
||||||
sharedLists?: UserListShareEntity[];
|
sharedLists?: UserListShareEntity[];
|
||||||
|
|
||||||
|
@OneToMany(() => UserTaskEntity, (task) => task.owner)
|
||||||
|
tasks?: UserTaskEntity[];
|
||||||
|
|
||||||
@OneToMany(() => ListTemplateShareEntity, (share) => share.user)
|
@OneToMany(() => ListTemplateShareEntity, (share) => share.user)
|
||||||
sharedTemplates?: ListTemplateShareEntity[];
|
sharedTemplates?: ListTemplateShareEntity[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { ListTemplateShareEntity } from '../list-templates/list-template-share.e
|
|||||||
import { TemplateSeedEntity } from '../list-templates/template-seed.entity';
|
import { TemplateSeedEntity } from '../list-templates/template-seed.entity';
|
||||||
import { UserListEntity } from '../lists/user-list.entity';
|
import { UserListEntity } from '../lists/user-list.entity';
|
||||||
import { UserListItemEntity } from '../lists/user-list-item.entity';
|
import { UserListItemEntity } from '../lists/user-list-item.entity';
|
||||||
|
import { UserTaskEntity } from '../tasks/user-task.entity';
|
||||||
import {
|
import {
|
||||||
databaseLoggerOptionsFromEnv,
|
databaseLoggerOptionsFromEnv,
|
||||||
parseDatabaseLogging,
|
parseDatabaseLogging,
|
||||||
@@ -43,6 +44,7 @@ export default new DataSource({
|
|||||||
TemplateSeedEntity,
|
TemplateSeedEntity,
|
||||||
UserListEntity,
|
UserListEntity,
|
||||||
UserListItemEntity,
|
UserListItemEntity,
|
||||||
|
UserTaskEntity,
|
||||||
WeeklyListSuggestionSnapshotEntity,
|
WeeklyListSuggestionSnapshotEntity,
|
||||||
],
|
],
|
||||||
migrations: ['src/database/migrations/*.ts'],
|
migrations: ['src/database/migrations/*.ts'],
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateUserTasks1781900000000 implements MigrationInterface {
|
||||||
|
name = 'CreateUserTasks1781900000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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`');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
listify-api/src/tasks/dto/create-task.dto.ts
Normal file
5
listify-api/src/tasks/dto/create-task.dto.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export class CreateTaskDto {
|
||||||
|
title?: string;
|
||||||
|
notes?: string;
|
||||||
|
dueDate?: string;
|
||||||
|
}
|
||||||
6
listify-api/src/tasks/dto/update-task.dto.ts
Normal file
6
listify-api/src/tasks/dto/update-task.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export class UpdateTaskDto {
|
||||||
|
title?: string;
|
||||||
|
notes?: string | null;
|
||||||
|
dueDate?: string;
|
||||||
|
completed?: boolean;
|
||||||
|
}
|
||||||
77
listify-api/src/tasks/tasks.controller.ts
Normal file
77
listify-api/src/tasks/tasks.controller.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
listify-api/src/tasks/tasks.module.ts
Normal file
14
listify-api/src/tasks/tasks.module.ts
Normal file
@@ -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 {}
|
||||||
87
listify-api/src/tasks/tasks.service.spec.ts
Normal file
87
listify-api/src/tasks/tasks.service.spec.ts
Normal file
@@ -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<UserTaskEntity>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tasksRepository = new InMemoryRepository<UserTaskEntity>();
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
250
listify-api/src/tasks/tasks.service.ts
Normal file
250
listify-api/src/tasks/tasks.service.ts
Normal file
@@ -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<UserTaskEntity>,
|
||||||
|
@Optional()
|
||||||
|
private readonly auditLogService?: AuditLogService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createTask(
|
||||||
|
ownerId: string,
|
||||||
|
createDto: CreateTaskDto,
|
||||||
|
): Promise<UserTask> {
|
||||||
|
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<UserTask[]> {
|
||||||
|
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<UserTask> {
|
||||||
|
return this.toUserTask(await this.findOwnedTask(ownerId, taskId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTask(
|
||||||
|
ownerId: string,
|
||||||
|
taskId: string,
|
||||||
|
updateDto: UpdateTaskDto,
|
||||||
|
): Promise<UserTask> {
|
||||||
|
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<UserTaskEntity> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
11
listify-api/src/tasks/tasks.types.ts
Normal file
11
listify-api/src/tasks/tasks.types.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
62
listify-api/src/tasks/user-task.entity.ts
Normal file
62
listify-api/src/tasks/user-task.entity.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -116,6 +116,9 @@ export class InMemoryRepository<T extends object> {
|
|||||||
const sortedRecords = [...records];
|
const sortedRecords = [...records];
|
||||||
const typedOrder = order as {
|
const typedOrder = order as {
|
||||||
name?: 'ASC' | 'DESC';
|
name?: 'ASC' | 'DESC';
|
||||||
|
createdAt?: 'ASC' | 'DESC';
|
||||||
|
dueDate?: 'ASC' | 'DESC';
|
||||||
|
completed?: 'ASC' | 'DESC';
|
||||||
reminderAt?: 'ASC' | 'DESC';
|
reminderAt?: 'ASC' | 'DESC';
|
||||||
items?: { position?: 'ASC' | 'DESC' };
|
items?: { position?: 'ASC' | 'DESC' };
|
||||||
};
|
};
|
||||||
@@ -142,6 +145,45 @@ export class InMemoryRepository<T extends object> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typedOrder?.dueDate) {
|
||||||
|
sortedRecords.sort((left, right) => {
|
||||||
|
const leftDate = String(
|
||||||
|
(left as Record<string, unknown>)['dueDate'] ?? '',
|
||||||
|
);
|
||||||
|
const rightDate = String(
|
||||||
|
(right as Record<string, unknown>)['dueDate'] ?? '',
|
||||||
|
);
|
||||||
|
return typedOrder.dueDate === 'DESC'
|
||||||
|
? rightDate.localeCompare(leftDate)
|
||||||
|
: leftDate.localeCompare(rightDate);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typedOrder?.completed) {
|
||||||
|
sortedRecords.sort((left, right) => {
|
||||||
|
const leftCompleted = Boolean(
|
||||||
|
(left as Record<string, unknown>)['completed'],
|
||||||
|
);
|
||||||
|
const rightCompleted = Boolean(
|
||||||
|
(right as Record<string, unknown>)['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<string, unknown>)['createdAt'];
|
||||||
|
const rightDate = (right as Record<string, unknown>)['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) {
|
if (typedOrder?.items?.position) {
|
||||||
for (const record of sortedRecords) {
|
for (const record of sortedRecords) {
|
||||||
const items = (record as { items?: Array<{ position: number }> }).items;
|
const items = (record as { items?: Array<{ position: number }> }).items;
|
||||||
|
|||||||
@@ -84,6 +84,16 @@
|
|||||||
<mat-icon matListItemIcon aria-hidden="true">dashboard_customize</mat-icon>
|
<mat-icon matListItemIcon aria-hidden="true">dashboard_customize</mat-icon>
|
||||||
<span matListItemTitle>Templates</span>
|
<span matListItemTitle>Templates</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
mat-list-item
|
||||||
|
routerLink="/tasks"
|
||||||
|
routerLinkActive="active-nav-link"
|
||||||
|
ariaCurrentWhenActive="page"
|
||||||
|
(click)="closeSidebarOnCompact()"
|
||||||
|
>
|
||||||
|
<mat-icon matListItemIcon aria-hidden="true">task_alt</mat-icon>
|
||||||
|
<span matListItemTitle>Tasks</span>
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
mat-list-item
|
mat-list-item
|
||||||
routerLink="/lists"
|
routerLink="/lists"
|
||||||
@@ -148,6 +158,15 @@
|
|||||||
<mat-icon aria-hidden="true">dashboard_customize</mat-icon>
|
<mat-icon aria-hidden="true">dashboard_customize</mat-icon>
|
||||||
<span>Templates</span>
|
<span>Templates</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
class="bottom-nav-link"
|
||||||
|
routerLink="/tasks"
|
||||||
|
routerLinkActive="active-bottom-link"
|
||||||
|
ariaCurrentWhenActive="page"
|
||||||
|
>
|
||||||
|
<mat-icon aria-hidden="true">task_alt</mat-icon>
|
||||||
|
<span>Tasks</span>
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
class="bottom-nav-link"
|
class="bottom-nav-link"
|
||||||
routerLink="/lists"
|
routerLink="/lists"
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ export const routes: Routes = [
|
|||||||
import('./dashboard/dashboard.component').then((module) => module.DashboardComponent),
|
import('./dashboard/dashboard.component').then((module) => module.DashboardComponent),
|
||||||
canActivate: [authGuard],
|
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', component: TemplatesComponent, canActivate: [authGuard] },
|
||||||
{
|
{
|
||||||
path: 'templates/new',
|
path: 'templates/new',
|
||||||
|
|||||||
@@ -148,7 +148,7 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
padding: 0.35rem 0.5rem calc(0.35rem + env(safe-area-inset-bottom));
|
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);
|
border-top: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent);
|
||||||
|
|||||||
202
listify-client/src/app/tasks/tasks.component.html
Normal file
202
listify-client/src/app/tasks/tasks.component.html
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
<section class="workspace-page">
|
||||||
|
<header class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>Tasks</h1>
|
||||||
|
<p>Einfache Tagesaufgaben ohne Listenbezug.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="day-controls" aria-label="Tag auswaehlen">
|
||||||
|
<button mat-icon-button type="button" aria-label="Vorheriger Tag" (click)="previousDay()">
|
||||||
|
<mat-icon aria-hidden="true">chevron_left</mat-icon>
|
||||||
|
</button>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Tag</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="date"
|
||||||
|
[value]="selectedDate()"
|
||||||
|
(change)="selectDate($any($event.target).value)"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
<button mat-icon-button type="button" aria-label="Naechster Tag" (click)="nextDay()">
|
||||||
|
<mat-icon aria-hidden="true">chevron_right</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button mat-stroked-button type="button" (click)="selectToday()">Heute</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<mat-card class="task-create-card" appearance="outlined">
|
||||||
|
<mat-card-content>
|
||||||
|
<mat-form-field appearance="outline" class="task-title-field">
|
||||||
|
<mat-label>Neuer Task</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
[value]="newTitle()"
|
||||||
|
(input)="newTitle.set($any($event.target).value)"
|
||||||
|
(keydown.enter)="createTask()"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Notiz</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="text"
|
||||||
|
[value]="newNotes()"
|
||||||
|
(input)="newNotes.set($any($event.target).value)"
|
||||||
|
(keydown.enter)="createTask()"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<button
|
||||||
|
mat-flat-button
|
||||||
|
type="button"
|
||||||
|
[disabled]="!newTitle().trim() || saving()"
|
||||||
|
(click)="createTask()"
|
||||||
|
>
|
||||||
|
@if (saving()) {
|
||||||
|
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
||||||
|
} @else {
|
||||||
|
<mat-icon aria-hidden="true">add</mat-icon>
|
||||||
|
}
|
||||||
|
Hinzufuegen
|
||||||
|
</button>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<mat-card class="state-card" appearance="outlined">
|
||||||
|
<mat-card-content>
|
||||||
|
<mat-progress-spinner mode="indeterminate" diameter="40" />
|
||||||
|
<h2>Tasks werden geladen</h2>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
} @else if (errorMessage()) {
|
||||||
|
<mat-card class="state-card error-state" appearance="outlined">
|
||||||
|
<mat-card-content>
|
||||||
|
<mat-icon aria-hidden="true">error</mat-icon>
|
||||||
|
<h2>Tasks konnten nicht geladen werden</h2>
|
||||||
|
<p>{{ errorMessage() }}</p>
|
||||||
|
<button mat-stroked-button type="button" (click)="loadTasks()">
|
||||||
|
<mat-icon aria-hidden="true">refresh</mat-icon>
|
||||||
|
Erneut laden
|
||||||
|
</button>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
} @else if (hasTasks()) {
|
||||||
|
<div class="task-summary" aria-label="Task Zusammenfassung">
|
||||||
|
<span>
|
||||||
|
<mat-icon aria-hidden="true">radio_button_unchecked</mat-icon>
|
||||||
|
{{ openTasks().length }} offen
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<mat-icon aria-hidden="true">check_circle</mat-icon>
|
||||||
|
{{ completedTasks().length }} erledigt
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-list">
|
||||||
|
@for (task of tasks(); track task.id) {
|
||||||
|
<mat-card class="task-card" [class.completed]="task.completed" appearance="outlined">
|
||||||
|
<mat-card-content>
|
||||||
|
<mat-checkbox
|
||||||
|
[checked]="task.completed"
|
||||||
|
[disabled]="updatingTaskId() === task.id"
|
||||||
|
(change)="toggleTask(task, $event.checked)"
|
||||||
|
[attr.aria-label]="task.title + ' erledigt'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if (editingTaskId() === task.id) {
|
||||||
|
<div class="task-edit-form">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Titel</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="text"
|
||||||
|
[value]="editTitle()"
|
||||||
|
(input)="editTitle.set($any($event.target).value)"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Notiz</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="text"
|
||||||
|
[value]="editNotes()"
|
||||||
|
(input)="editNotes.set($any($event.target).value)"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Tag</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="date"
|
||||||
|
[value]="editDueDate()"
|
||||||
|
(change)="editDueDate.set($any($event.target).value)"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
<div class="task-edit-actions">
|
||||||
|
<button
|
||||||
|
mat-flat-button
|
||||||
|
type="button"
|
||||||
|
[disabled]="!editTitle().trim() || updatingTaskId() === task.id"
|
||||||
|
(click)="saveEdit(task)"
|
||||||
|
>
|
||||||
|
<mat-icon aria-hidden="true">save</mat-icon>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button mat-button type="button" (click)="cancelEdit()">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="task-content">
|
||||||
|
<h2>{{ task.title }}</h2>
|
||||||
|
@if (task.notes) {
|
||||||
|
<p>{{ task.notes }}</p>
|
||||||
|
}
|
||||||
|
<span>
|
||||||
|
<mat-icon aria-hidden="true">event</mat-icon>
|
||||||
|
{{ formatDay(task.dueDate) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-actions">
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
type="button"
|
||||||
|
aria-label="Task bearbeiten"
|
||||||
|
(click)="startEdit(task)"
|
||||||
|
>
|
||||||
|
<mat-icon aria-hidden="true">edit</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
type="button"
|
||||||
|
aria-label="Task loeschen"
|
||||||
|
[disabled]="deletingTaskId() === task.id"
|
||||||
|
(click)="deleteTask(task)"
|
||||||
|
>
|
||||||
|
@if (deletingTaskId() === task.id) {
|
||||||
|
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
||||||
|
} @else {
|
||||||
|
<mat-icon aria-hidden="true">delete</mat-icon>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<mat-card class="state-card" appearance="outlined">
|
||||||
|
<mat-card-content>
|
||||||
|
<mat-icon aria-hidden="true">task_alt</mat-icon>
|
||||||
|
<h2>Keine Tasks fuer diesen Tag</h2>
|
||||||
|
<p>Lege oben eine Aufgabe an, die an diesem Tag erledigt werden soll.</p>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
164
listify-client/src/app/tasks/tasks.component.scss
Normal file
164
listify-client/src/app/tasks/tasks.component.scss
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
263
listify-client/src/app/tasks/tasks.component.ts
Normal file
263
listify-client/src/app/tasks/tasks.component.ts
Normal file
@@ -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<UserTask[]>([]);
|
||||||
|
protected readonly loading = signal(true);
|
||||||
|
protected readonly saving = signal(false);
|
||||||
|
protected readonly updatingTaskId = signal<string | null>(null);
|
||||||
|
protected readonly deletingTaskId = signal<string | null>(null);
|
||||||
|
protected readonly errorMessage = signal<string | null>(null);
|
||||||
|
protected readonly selectedDate = signal(this.todayKey());
|
||||||
|
protected readonly newTitle = signal('');
|
||||||
|
protected readonly newNotes = signal('');
|
||||||
|
protected readonly editingTaskId = signal<string | null>(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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
listify-client/src/app/tasks/tasks.models.ts
Normal file
24
listify-client/src/app/tasks/tasks.models.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
28
listify-client/src/app/tasks/tasks.service.ts
Normal file
28
listify-client/src/app/tasks/tasks.service.ts
Normal file
@@ -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<UserTask[]> {
|
||||||
|
const params = date ? new HttpParams().set('date', date) : undefined;
|
||||||
|
|
||||||
|
return this.http.get<UserTask[]>(this.apiUrl, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
createTask(data: CreateTaskRequest): Observable<UserTask> {
|
||||||
|
return this.http.post<UserTask>(this.apiUrl, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTask(taskId: string, data: UpdateTaskRequest): Observable<UserTask> {
|
||||||
|
return this.http.patch<UserTask>(`${this.apiUrl}/${taskId}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteTask(taskId: string): Observable<{ message: string }> {
|
||||||
|
return this.http.delete<{ message: string }>(`${this.apiUrl}/${taskId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user