diff --git a/CHANGELOG.md b/CHANGELOG.md index 106beb62..dff01349 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- [shared] Added reusable EmojiPicker component with inline expandable dropdown, bilingual keyword search (EN/DE), scrollable grid view, and categorized emoji selection covering 200+ common emojis. +- [todo] Integrated EmojiPicker into todo list creation and edit modals with vertical layout for easier emoji selection. +- [todo] Added shared todo list functionality enabling collaborative editing via unique shared IDs. +- [todo] Added API module (`todo.api.ts`) for shared list operations following project patterns. +- [todo] Added "Make local" button to convert shared lists back to local-only mode. +- [todo] Added conflict resolution notifications when shared list is updated by another user. +- [todo] Added duplicate detection when importing already-imported shared lists. +- [todo] Added error notifications for failed shared list imports with localized messages. +- [todo] Added confirmation modal before deleting todo lists with separate warnings for local vs shared lists. +- [todo] Added server-side deletion for shared lists - when a shared list is deleted, it's removed from the backend. +- [todo] Added cross-user deletion detection - automatically removes shared lists from all users when deleted by another user. +- [todo] Added notification when a shared list is deleted by another user, with automatic list selection fallback. - [memorandum] Added "Copy link URL" option to link context menu for easy URL copying to clipboard. - [memorandum] Added arrow-based reordering system with up/down buttons and "Move to Top/Bottom" context menu options for folders and links. - [memorandum] Added editable preset overlay with JSON validation, allowing direct editing of preset configuration with real-time validation and sync to localStorage/cloud. @@ -15,6 +27,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- [shared] Refactored emoji data (keywords and categories) from inline component code into separate utility module (`frontend/src/lib/util/emoji-data.ts`) for better organization and reusability. +- [todo] Refactored backend architecture into three-layer design: `todo-persistence` (database layer), `todo-provider` (business logic), and `todo-controller` (API endpoints) for better separation of concerns and testability. +- [todo] Added comprehensive unit test coverage (39 tests) for all three layers with proper mocking and error handling. +- [todo] Renamed `TodoMongoDbService` to `TodoPersistenceService` and `TodoService` to `TodoProviderService` for clearer naming conventions. +- [todo] Cleaned up duplicate naming in module files: renamed `todo-todo-*.module.ts` to `todo-*.module.ts` and updated all class names and imports (e.g., `TodoTodoControllerModule` โ†’ `TodoControllerModule`). +- [todo] Replaced hardcoded "Uncategorized" category string with empty string throughout codebase - UI displays localized "General" / "Allgemein" label. +- [todo] Improved shared ID copy UI: icon-only button integrated with input field, success notification displayed at bottom of modal. +- [todo] Changed default view to category view (was: classic view) for better organization of todos. +- [todo] Added persistence for last viewed todo list - page reload now returns to the previously viewed list. +- [todo] Updated "Uncategorized" category label to more user-friendly "General" (EN) / "Allgemein" (DE). +- [todo] Improved TodoListOverlay modal UX by moving share/unshare button to modal action buttons and styling delete button as danger/red. +- [todo] Refactored shared list sync from push-based to pull-based polling (5-second intervals) for better conflict detection. +- [todo] Enhanced shared list creation to immediately push existing todos to server after creating shared list. +- [todo] Updated shared list endpoints to be public (no authentication required) for GET, POST, PUT, and DELETE operations. +- [todo] Improved sync logic with debounced updates and automatic conflict resolution via server-side version merging. +- [todo] Replaced inline fetch() calls with dedicated API module following project conventions. +- [todo] Updated API functions to return status codes for better error handling and 404 detection. - [todo] Migrated todo route and all corresponding components (TodoList, TodoInput, Todo, TodoListOverlay, +page) to Svelte 5 runes syntax ($props, $state, $derived, $effect) for improved reactivity and type safety. - [todo] Enhanced Todo component with improved UI/UX including clickable rows, better visual feedback, and proper accessibility. - [todo] Improved TodoList with sorted todos (unchecked first), better state management using $derived, and enhanced history management. @@ -42,6 +71,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- [shared] Fixed EmojiPicker modal integration issues by replacing Carbon's Popover with a custom inline expandable dropdown that works reliably within modal constraints. +- [todo] Fixed missing sync when re-adding todos from history - now properly syncs to shared lists. +- [todo] Fixed bi-directional sync issue where changes in one browser didn't appear in other browsers - converted sync setup from onMount to reactive $effect. +- [todo] Fixed Toggle component error by converting `isCategoryView` from derived to state variable, enabling proper two-way binding. +- [todo] Fixed blank display in category view when list has no entries - now shows helpful empty state message. +- [todo] Fixed issue where no list was selected after deleting a list - now automatically selects the first remaining list. +- [todo] Fixed TOCTOU race condition in shared list updates by implementing atomic `findOneAndUpdate` with version check in query filter. +- [todo] Fixed generic error responses by replacing `throw new Error()` with NestJS HTTP exceptions (`NotFoundException`, `ConflictException`). +- [todo] Fixed missing history field persistence by adding to update DTO and MongoDB service signature. +- [todo] Fixed shared list deletion 400 error by removing manual UUID assignment and letting MongoDB auto-generate ObjectIds. +- [todo] Fixed DELETE request 400 error by removing Content-Type header from requests without body. +- [todo] Fixed cross-user deletion detection by adding global polling for all shared lists (every 10 seconds) in addition to per-list polling. +- [todo] Removed duplicate CSS blocks in TodoListOverlay component (`.share_section`, `.shared_id_container`, `.copy_button`, `.copied_notification`). +- [todo] Removed unused `importSharedId` state variable from TodoListOverlay component. +- [todo] Removed unused `selectRandomTagColor` function from TodoList component. - [global] Fixed erroneous setLocale call in background color store causing i18n warnings. - [memorandum] Fixed an issue where editing a folder in memorandum changed the wrong folders settings. - [memorandum] Fixed folder ID duplication issues caused by drag-and-drop. diff --git a/backend/apps/tilloh-dev/src/main.ts b/backend/apps/tilloh-dev/src/main.ts index 7749f8e4..d116739d 100644 --- a/backend/apps/tilloh-dev/src/main.ts +++ b/backend/apps/tilloh-dev/src/main.ts @@ -4,6 +4,7 @@ import { JokesModule } from '@backend/jokes'; import { MemorandumModule } from '@backend/memorandum'; import { OcrModule } from '@backend/ocr'; import { SharedControllerHealthModule } from '@backend/shared-controller-health'; +import { TodoControllerModule } from '@backend/todo-controller'; import { metricsControllerFactory } from '@backend/shared-metrics-controller'; import { AdminGuard, @@ -72,6 +73,7 @@ import { EnvironmentVariables, validate } from './env.validation'; JokesModule, ChatModule, OcrModule, + TodoControllerModule, ], providers: [ Logger, diff --git a/backend/jest.config.ts b/backend/jest.config.ts index 7a5e3369..6b3f2d6e 100644 --- a/backend/jest.config.ts +++ b/backend/jest.config.ts @@ -1,5 +1,5 @@ import { getJestProjectsAsync } from '@nx/jest'; -export default { +export default async () => ({ projects: await getJestProjectsAsync(), -}; +}); diff --git a/backend/libs/shared/common/types/src/index.ts b/backend/libs/shared/common/types/src/index.ts index d4304f23..2be85a5f 100644 --- a/backend/libs/shared/common/types/src/index.ts +++ b/backend/libs/shared/common/types/src/index.ts @@ -11,3 +11,4 @@ export * from './lib/message.entity'; export * from './lib/ocr-space.dto'; export * from './lib/typing.dto'; export * from './lib/update-message.dto'; +export * from './lib/todo.dto'; diff --git a/backend/libs/shared/common/types/src/lib/todo.dto.ts b/backend/libs/shared/common/types/src/lib/todo.dto.ts new file mode 100644 index 00000000..ce4dfb1f --- /dev/null +++ b/backend/libs/shared/common/types/src/lib/todo.dto.ts @@ -0,0 +1,133 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString, IsArray, IsBoolean, IsNumber } from 'class-validator'; + +export class TodoItemDto { + @ApiProperty({ description: 'Todo item ID' }) + @IsNotEmpty() + @IsString() + id: string; + + @ApiProperty({ description: 'Todo item title' }) + @IsNotEmpty() + @IsString() + title: string; + + @ApiProperty({ description: 'Todo item completion status', required: false }) + @IsOptional() + @IsBoolean() + done?: boolean; + + @ApiProperty({ description: 'Todo item amount', required: false }) + @IsOptional() + @IsString() + amount?: string; + + @ApiProperty({ description: 'Todo item category', required: false }) + @IsOptional() + @IsString() + category?: string; +} + +export class SharedTodoListDto { + @ApiProperty({ description: 'Shared todo list ID' }) + @IsNotEmpty() + @IsString() + _id: string; + + @ApiProperty({ description: 'Shared todo list name' }) + @IsNotEmpty() + @IsString() + name: string; + + @ApiProperty({ description: 'Shared todo list emoji' }) + @IsNotEmpty() + @IsString() + emoji: string; + + @ApiProperty({ description: 'Todo items in the list', type: [TodoItemDto] }) + @IsArray() + todos: TodoItemDto[]; + + @ApiProperty({ description: 'History of changes', type: [String], required: false }) + @IsOptional() + @IsArray() + history?: string[]; + + @ApiProperty({ description: 'Version for optimistic locking' }) + @IsNumber() + version: number; + + @ApiProperty({ description: 'Creation date', required: false }) + @IsOptional() + created: Date; + + @ApiProperty({ description: 'Update date', required: false }) + @IsOptional() + updated: Date; +} + +export class GetSharedTodoListsOutputDto extends SharedTodoListDto {} + +export class GetSharedTodoListInputDto { + @ApiProperty({ description: 'Shared todo list ID' }) + @IsNotEmpty() + @IsString() + id: string; +} + +export class GetSharedTodoListOutputDto extends SharedTodoListDto {} + +export class CreateSharedTodoListInputDto { + @ApiProperty({ description: 'Shared todo list name' }) + @IsNotEmpty() + @IsString() + name: string; + + @ApiProperty({ description: 'Shared todo list emoji' }) + @IsNotEmpty() + @IsString() + emoji: string; +} + +export class CreateSharedTodoListOutputDto extends SharedTodoListDto {} + +export class UpdateSharedTodoListInputDto { + @ApiProperty({ description: 'Shared todo list ID' }) + @IsNotEmpty() + @IsString() + id: string; + + @ApiProperty({ description: 'Shared todo list name' }) + @IsNotEmpty() + @IsString() + name: string; + + @ApiProperty({ description: 'Shared todo list emoji' }) + @IsNotEmpty() + @IsString() + emoji: string; + + @ApiProperty({ description: 'Todo items in the list', type: [TodoItemDto] }) + @IsArray() + todos: TodoItemDto[]; + + @ApiProperty({ description: 'History of todo entries', type: [String], required: false }) + @IsOptional() + @IsArray() + history?: string[]; + + @ApiProperty({ description: 'Current version for optimistic locking' }) + @IsNumber() + version: number; +} + +export class UpdateSharedTodoListOutputDto extends SharedTodoListDto {} + +export class RemoveSharedTodoListInputDto { + @ApiProperty({ description: 'Shared todo list ID' }) + @IsNotEmpty() + @IsString() + id: string; +} + +export class RemoveSharedTodoListOutputDto extends SharedTodoListDto {} diff --git a/backend/libs/shared/provider/todo/src/index.ts b/backend/libs/shared/provider/todo/src/index.ts new file mode 100644 index 00000000..659690f5 --- /dev/null +++ b/backend/libs/shared/provider/todo/src/index.ts @@ -0,0 +1,2 @@ +export * from './lib/todo.module'; +export * from './lib/todo.service'; diff --git a/backend/libs/shared/provider/todo/src/lib/schema/todo.schema.ts b/backend/libs/shared/provider/todo/src/lib/schema/todo.schema.ts new file mode 100644 index 00000000..602f16ff --- /dev/null +++ b/backend/libs/shared/provider/todo/src/lib/schema/todo.schema.ts @@ -0,0 +1,49 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document, Types } from 'mongoose'; + +export type SharedTodoListDocument = SharedTodoList & Document; + +class TodoItem { + @Prop({ required: true }) + id: string; + + @Prop({ required: true }) + title: string; + + @Prop({ required: false }) + done?: boolean; + + @Prop({ required: false }) + amount?: string; + + @Prop({ required: false }) + category?: string; +} + +@Schema({ collection: 'shared_todo_lists' }) +export class SharedTodoList { + _id?: Types.ObjectId; + + @Prop({ required: true }) + name!: string; + + @Prop({ required: true }) + emoji!: string; + + @Prop({ type: [TodoItem], required: true, default: [] }) + todos!: TodoItem[]; + + @Prop({ type: [String], required: false, default: [] }) + history?: string[]; + + @Prop({ type: Number, required: true, default: 1 }) + version!: number; + + @Prop({ type: Date, required: true, default: () => new Date() }) + created!: Date; + + @Prop({ type: Date, required: true, default: () => new Date() }) + updated!: Date; +} + +export const SharedTodoListSchema = SchemaFactory.createForClass(SharedTodoList); \ No newline at end of file diff --git a/backend/libs/shared/provider/todo/src/lib/todo-mongodb.service.ts b/backend/libs/shared/provider/todo/src/lib/todo-mongodb.service.ts new file mode 100644 index 00000000..5baef000 --- /dev/null +++ b/backend/libs/shared/provider/todo/src/lib/todo-mongodb.service.ts @@ -0,0 +1,89 @@ +import { Injectable, Logger, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { SharedTodoList, SharedTodoListDocument } from './schema/todo.schema'; + +@Injectable() +export class TodoMongoDbService { + private readonly logger = new Logger(TodoMongoDbService.name); + + constructor( + @InjectModel(SharedTodoList.name) + private sharedTodoListModel: Model, + ) {} + + async findAll(): Promise { + this.logger.log('Finding all shared todo lists'); + return this.sharedTodoListModel.find().exec(); + } + + async findOne(id: string): Promise { + this.logger.log(`Finding shared todo list with id: ${id}`); + return this.sharedTodoListModel.findById(id).exec(); + } + + async create( + name: string, + emoji: string, + ): Promise { + this.logger.log(`Creating new shared todo list: ${name}`); + const newList = new this.sharedTodoListModel({ + name, + emoji, + todos: [], + history: [], + version: 1, + }); + return newList.save(); + } + + async update( + id: string, + name: string, + emoji: string, + todos: any[], + history: string[], + currentVersion: number, + ): Promise { + this.logger.log(`Updating shared todo list with id: ${id}`); + + // Atomic update: include version check in the query filter to prevent TOCTOU race condition + const result = await this.sharedTodoListModel + .findOneAndUpdate( + { _id: id, version: currentVersion }, + { + name, + emoji, + todos, + history, + version: currentVersion + 1, + updated: new Date(), + }, + { new: true }, + ) + .exec(); + + // If no result, determine if it's a "not found" or "version conflict" + if (!result) { + const list = await this.sharedTodoListModel.findById(id).exec(); + if (!list) { + this.logger.warn(`Shared todo list not found: ${id}`); + throw new NotFoundException(`Shared todo list with id ${id} not found`); + } + // List exists but version didn't match - conflict + this.logger.warn( + `Version conflict for list ${id}. Expected: ${currentVersion}, Current: ${list.version}`, + ); + throw new ConflictException( + `Version conflict. Expected version ${currentVersion}, but current version is ${list.version}`, + ); + } + + return result; + } + + async remove(id: string): Promise { + this.logger.log(`Removing shared todo list with id: ${id}`); + return this.sharedTodoListModel.findByIdAndDelete(id).exec(); + } +} \ No newline at end of file diff --git a/backend/libs/shared/provider/todo/src/lib/todo.module.ts b/backend/libs/shared/provider/todo/src/lib/todo.module.ts new file mode 100644 index 00000000..231680a3 --- /dev/null +++ b/backend/libs/shared/provider/todo/src/lib/todo.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { TodoService } from './todo.service'; +import { TodoMongoDbService } from './todo-mongodb.service'; +import { SharedTodoList, SharedTodoListSchema } from './schema/todo.schema'; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: SharedTodoList.name, schema: SharedTodoListSchema }, + ]), + ], + controllers: [], + providers: [TodoService, TodoMongoDbService], + exports: [TodoService], +}) +export class SharedTodoModule {} \ No newline at end of file diff --git a/backend/libs/shared/provider/todo/src/lib/todo.service.ts b/backend/libs/shared/provider/todo/src/lib/todo.service.ts new file mode 100644 index 00000000..bb676b63 --- /dev/null +++ b/backend/libs/shared/provider/todo/src/lib/todo.service.ts @@ -0,0 +1,112 @@ +import { + CreateSharedTodoListInputDto, + CreateSharedTodoListOutputDto, + GetSharedTodoListInputDto, + GetSharedTodoListOutputDto, + GetSharedTodoListsOutputDto, + RemoveSharedTodoListInputDto, + RemoveSharedTodoListOutputDto, + UpdateSharedTodoListInputDto, + UpdateSharedTodoListOutputDto, +} from '@backend/shared-types'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { FilterQuery } from 'mongoose'; +import { TodoMongoDbService } from './todo-mongodb.service'; +import { SharedTodoListDocument } from './schema/todo.schema'; + +@Injectable() +export class TodoService { + private readonly logger = new Logger(TodoService.name); + constructor(private todoMongoDbService: TodoMongoDbService) {} + + /** + * Fetches all shared todo lists. + * + * @param filter Optional param to filter for specific shared todo list results. + * @returns An array of shared todo list objects. + */ + async listSharedTodoLists( + filter: FilterQuery = {}, + ): Promise { + this.logger.log('Return a list of all shared todo lists.'); + return (await this.todoMongoDbService.findAll()) as any; + } + + /** + * Fetches a shared todo list by its id. + * + * @param getSharedTodoListInput The id of the shared todo list. + * @returns A single shared todo list object. + */ + async getSharedTodoList( + getSharedTodoListInput: GetSharedTodoListInputDto, + ): Promise { + this.logger.log('Returns a single shared todo list.'); + const result = await this.todoMongoDbService.findOne( + getSharedTodoListInput.id, + ); + if (!result) { + throw new NotFoundException( + `Shared todo list with id ${getSharedTodoListInput.id} not found`, + ); + } + return result as any; + } + + /** + * Creates a shared todo list. + * + * @param createSharedTodoListInputDto The name and emoji of the shared todo list. + * @returns The created shared todo list object. + */ + async createSharedTodoList( + createSharedTodoListInputDto: CreateSharedTodoListInputDto, + ): Promise { + this.logger.log('Creates a new shared todo list.'); + return (await this.todoMongoDbService.create( + createSharedTodoListInputDto.name, + createSharedTodoListInputDto.emoji, + )) as any; + } + + /** + * Updates a shared todo list. + * + * @param updateSharedTodoListInputDto The shared todo list data to update. + * @returns The updated shared todo list object. + */ + async updateSharedTodoList( + updateSharedTodoListInputDto: UpdateSharedTodoListInputDto, + ): Promise { + this.logger.log('Updates a shared todo list.'); + return (await this.todoMongoDbService.update( + updateSharedTodoListInputDto.id, + updateSharedTodoListInputDto.name, + updateSharedTodoListInputDto.emoji, + updateSharedTodoListInputDto.todos, + updateSharedTodoListInputDto.history || [], + updateSharedTodoListInputDto.version, + )) as any; + } + + /** + * Removes a shared todo list. + * + * @param removeSharedTodoListInputDto The id of the shared todo list to remove. + * @returns The removed shared todo list object. + */ + async removeSharedTodoList( + removeSharedTodoListInputDto: RemoveSharedTodoListInputDto, + ): Promise { + this.logger.log('Removes a shared todo list.'); + const result = await this.todoMongoDbService.remove( + removeSharedTodoListInputDto.id, + ); + if (!result) { + throw new NotFoundException( + `Shared todo list with id ${removeSharedTodoListInputDto.id} not found`, + ); + } + return result as any; + } +} diff --git a/backend/libs/todo/todo-controller/project.json b/backend/libs/todo/todo-controller/project.json index 4732ccf9..02fb688d 100644 --- a/backend/libs/todo/todo-controller/project.json +++ b/backend/libs/todo/todo-controller/project.json @@ -1,5 +1,5 @@ { - "name": "todo-todo-controller", + "name": "todo-controller", "$schema": "../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "libs/todo/todo-controller/src", "projectType": "library", diff --git a/backend/libs/todo/todo-controller/src/index.ts b/backend/libs/todo/todo-controller/src/index.ts index 258508c8..8f964a44 100644 --- a/backend/libs/todo/todo-controller/src/index.ts +++ b/backend/libs/todo/todo-controller/src/index.ts @@ -1 +1 @@ -export * from './lib/todo-todo-controller.module'; +export * from './lib/todo-controller.module'; diff --git a/backend/libs/todo/todo-controller/src/lib/todo-controller.module.ts b/backend/libs/todo/todo-controller/src/lib/todo-controller.module.ts new file mode 100644 index 00000000..59d91e81 --- /dev/null +++ b/backend/libs/todo/todo-controller/src/lib/todo-controller.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TodoController } from './todo.controller'; +import { TodoProviderModule } from '@backend/todo-provider'; + +@Module({ + imports: [TodoProviderModule], + controllers: [TodoController], + providers: [], + exports: [], +}) +export class TodoControllerModule {} diff --git a/backend/libs/todo/todo-controller/src/lib/todo-todo-controller.module.ts b/backend/libs/todo/todo-controller/src/lib/todo-todo-controller.module.ts deleted file mode 100644 index e1e8bc2b..00000000 --- a/backend/libs/todo/todo-controller/src/lib/todo-todo-controller.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; - -@Module({ - controllers: [], - providers: [], - exports: [], -}) -export class TodoTodoControllerModule {} diff --git a/backend/libs/todo/todo-controller/src/lib/todo.controller.spec.ts b/backend/libs/todo/todo-controller/src/lib/todo.controller.spec.ts new file mode 100644 index 00000000..1e22df4e --- /dev/null +++ b/backend/libs/todo/todo-controller/src/lib/todo.controller.spec.ts @@ -0,0 +1,240 @@ +import { + CreateSharedTodoListInputDto, + CreateSharedTodoListOutputDto, + GetSharedTodoListInputDto, + GetSharedTodoListOutputDto, + GetSharedTodoListsOutputDto, + RemoveSharedTodoListInputDto, + RemoveSharedTodoListOutputDto, + UpdateSharedTodoListInputDto, + UpdateSharedTodoListOutputDto, +} from '@backend/shared-types'; +import { TodoProviderService } from '@backend/todo-provider'; +import { Test, TestingModule } from '@nestjs/testing'; +import { TodoController } from './todo.controller'; + +describe('TodoController', () => { + let controller: TodoController; + let todoProviderService: TodoProviderService; + + const mockSharedTodoList: GetSharedTodoListOutputDto = { + _id: 'mock-id-123', + name: 'Test List', + emoji: '๐Ÿ“', + todos: [ + { + id: 'todo-1', + title: 'Test Todo', + done: false, + amount: '1x', + category: 'Test', + }, + ], + history: ['Test Todo'], + version: 1, + created: new Date('2024-01-01'), + updated: new Date('2024-01-01'), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: TodoProviderService, + useValue: { + listSharedTodoLists: jest.fn(), + getSharedTodoList: jest.fn(), + createSharedTodoList: jest.fn(), + updateSharedTodoList: jest.fn(), + removeSharedTodoList: jest.fn(), + }, + }, + ], + controllers: [TodoController], + }).compile(); + + controller = module.get(TodoController); + todoProviderService = module.get(TodoProviderService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('Controller should be defined.', () => { + expect(controller).toBeDefined(); + }); + + describe('getSharedTodoLists', () => { + it('should return a list of shared todo lists.', async () => { + // arrange + const todoLists: GetSharedTodoListsOutputDto[] = [ + mockSharedTodoList, + { ...mockSharedTodoList, _id: 'mock-id-456', name: 'Another List' }, + ]; + jest + .spyOn(todoProviderService, 'listSharedTodoLists') + .mockResolvedValue(todoLists as any); + + // act & assert + await expect(controller.getSharedTodoLists()).resolves.toEqual(todoLists); + expect(todoProviderService.listSharedTodoLists).toHaveBeenCalledWith({}); + }); + + it('should pass filter query to service.', async () => { + // arrange + const filter = { name: 'Test' }; + const todoLists: GetSharedTodoListsOutputDto[] = [mockSharedTodoList]; + jest + .spyOn(todoProviderService, 'listSharedTodoLists') + .mockResolvedValue(todoLists as any); + + // act + await controller.getSharedTodoLists(filter); + + // assert + expect(todoProviderService.listSharedTodoLists).toHaveBeenCalledWith( + filter, + ); + }); + }); + + describe('getSharedTodoList', () => { + it('should return a single shared todo list.', async () => { + // arrange + const input: GetSharedTodoListInputDto = { id: 'mock-id-123' }; + jest + .spyOn(todoProviderService, 'getSharedTodoList') + .mockResolvedValue(mockSharedTodoList as any); + + // act & assert + await expect(controller.getSharedTodoList(input)).resolves.toEqual( + mockSharedTodoList, + ); + expect(todoProviderService.getSharedTodoList).toHaveBeenCalledWith(input); + }); + }); + + describe('createSharedTodoList', () => { + it('should create and return a new shared todo list.', async () => { + // arrange + const input: CreateSharedTodoListInputDto = { + name: 'New List', + emoji: 'โœจ', + }; + const output: CreateSharedTodoListOutputDto = { + ...mockSharedTodoList, + name: 'New List', + emoji: 'โœจ', + todos: [], + history: [], + version: 0, + }; + jest + .spyOn(todoProviderService, 'createSharedTodoList') + .mockResolvedValue(output as any); + + // act & assert + await expect(controller.createSharedTodoList(input)).resolves.toEqual( + output, + ); + expect(todoProviderService.createSharedTodoList).toHaveBeenCalledWith( + input, + ); + }); + }); + + describe('updateSharedTodoList', () => { + it('should update and return the shared todo list.', async () => { + // arrange + const id = 'mock-id-123'; + const input: UpdateSharedTodoListInputDto = { + id, + name: 'Updated List', + emoji: '๐Ÿ”„', + todos: [ + { + id: 'todo-1', + title: 'Updated Todo', + done: true, + amount: '2x', + category: 'Updated', + }, + ], + history: ['Updated Todo'], + version: 1, + }; + const output: UpdateSharedTodoListOutputDto = { + ...mockSharedTodoList, + name: input.name, + emoji: input.emoji, + todos: input.todos, + history: input.history, + _id: id, + version: 2, + }; + jest + .spyOn(todoProviderService, 'updateSharedTodoList') + .mockResolvedValue(output as any); + + // act & assert + await expect(controller.updateSharedTodoList(id, input)).resolves.toEqual( + output, + ); + expect(todoProviderService.updateSharedTodoList).toHaveBeenCalledWith({ + ...input, + id, + }); + }); + + it('should merge id parameter with input dto.', async () => { + // arrange + const id = 'test-id-789'; + const input: UpdateSharedTodoListInputDto = { + id, + name: 'Test', + emoji: '๐Ÿ“‹', + todos: [], + history: [], + version: 5, + }; + const mockOutput: UpdateSharedTodoListOutputDto = { + _id: id, + ...input, + created: new Date('2024-01-01'), + updated: new Date('2024-01-01'), + }; + jest + .spyOn(todoProviderService, 'updateSharedTodoList') + .mockResolvedValue(mockOutput as any); + + // act + await controller.updateSharedTodoList(id, input); + + // assert + expect(todoProviderService.updateSharedTodoList).toHaveBeenCalledWith({ + ...input, + id, + }); + }); + }); + + describe('removeSharedTodoList', () => { + it('should remove and return the deleted shared todo list.', async () => { + // arrange + const input: RemoveSharedTodoListInputDto = { id: 'mock-id-123' }; + const output: RemoveSharedTodoListOutputDto = mockSharedTodoList; + jest + .spyOn(todoProviderService, 'removeSharedTodoList') + .mockResolvedValue(output as any); + + // act & assert + await expect(controller.removeSharedTodoList(input)).resolves.toEqual( + output, + ); + expect(todoProviderService.removeSharedTodoList).toHaveBeenCalledWith( + input, + ); + }); + }); +}); diff --git a/backend/libs/todo/todo-controller/src/lib/todo.controller.ts b/backend/libs/todo/todo-controller/src/lib/todo.controller.ts new file mode 100644 index 00000000..aa936f37 --- /dev/null +++ b/backend/libs/todo/todo-controller/src/lib/todo.controller.ts @@ -0,0 +1,109 @@ +import { + CreateSharedTodoListInputDto, + CreateSharedTodoListOutputDto, + GetSharedTodoListInputDto, + GetSharedTodoListOutputDto, + GetSharedTodoListsOutputDto, + RemoveSharedTodoListInputDto, + RemoveSharedTodoListOutputDto, + UpdateSharedTodoListInputDto, + UpdateSharedTodoListOutputDto, +} from '@backend/shared-types'; +import { Public } from '@backend/util'; +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, +} from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiBearerAuth, + ApiConflictResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; +import { TodoProviderService } from '@backend/todo-provider'; + +@ApiTags('shared-todo-lists') +@Controller('/shared-todo-lists') +export class TodoController { + constructor(private todoProviderService: TodoProviderService) {} + + @ApiBearerAuth() + @ApiOkResponse({ + description: 'List of all shared todo lists successfully returned.', + type: [GetSharedTodoListsOutputDto], + }) + @ApiUnauthorizedResponse({ description: 'Unauthorized request.' }) + @ApiBadRequestResponse({ description: 'Bad or malformed request.' }) + @Get() + getSharedTodoLists(@Query() filter?: any) { + const filterQuery = filter || {}; + return this.todoProviderService.listSharedTodoLists(filterQuery); + } + + @Public() + @ApiOkResponse({ + description: 'Shared todo list successfully returned.', + type: GetSharedTodoListOutputDto, + }) + @ApiNotFoundResponse({ description: 'Shared todo list not found.' }) + @ApiBadRequestResponse({ description: 'Bad or malformed request.' }) + @Get(':id') + getSharedTodoList(@Param() getSharedTodoListInput: GetSharedTodoListInputDto) { + return this.todoProviderService.getSharedTodoList(getSharedTodoListInput); + } + + @Public() + @ApiOkResponse({ + description: 'Shared todo list successfully created.', + type: CreateSharedTodoListOutputDto, + }) + @ApiBadRequestResponse({ description: 'Bad or malformed request.' }) + @Post() + createSharedTodoList( + @Body() createSharedTodoListInputDto: CreateSharedTodoListInputDto, + ) { + return this.todoProviderService.createSharedTodoList(createSharedTodoListInputDto); + } + + @Public() + @ApiOkResponse({ + description: 'Shared todo list successfully updated.', + type: UpdateSharedTodoListOutputDto, + }) + @ApiNotFoundResponse({ description: 'Shared todo list not found.' }) + @ApiConflictResponse({ description: 'Version conflict.' }) + @ApiBadRequestResponse({ description: 'Bad or malformed request.' }) + @Put(':id') + updateSharedTodoList( + @Param('id') id: string, + @Body() updateSharedTodoListInputDto: UpdateSharedTodoListInputDto, + ) { + return this.todoProviderService.updateSharedTodoList({ + ...updateSharedTodoListInputDto, + id, + }); + } + + @Public() + @ApiOkResponse({ + description: 'Shared todo list successfully removed.', + type: RemoveSharedTodoListOutputDto, + }) + @ApiNotFoundResponse({ description: 'Shared todo list not found.' }) + @ApiBadRequestResponse({ description: 'Bad or malformed request.' }) + @Delete(':id') + removeSharedTodoList( + @Param() removeSharedTodoListInputDto: RemoveSharedTodoListInputDto, + ) { + return this.todoProviderService.removeSharedTodoList(removeSharedTodoListInputDto); + } +} \ No newline at end of file diff --git a/backend/libs/todo/todo-persistence/project.json b/backend/libs/todo/todo-persistence/project.json index feb1bf63..72b10b11 100644 --- a/backend/libs/todo/todo-persistence/project.json +++ b/backend/libs/todo/todo-persistence/project.json @@ -1,5 +1,5 @@ { - "name": "todo-todo-persistence", + "name": "todo-persistence", "$schema": "../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "libs/todo/todo-persistence/src", "projectType": "library", diff --git a/backend/libs/todo/todo-persistence/src/index.ts b/backend/libs/todo/todo-persistence/src/index.ts index 209ed175..faae6d84 100644 --- a/backend/libs/todo/todo-persistence/src/index.ts +++ b/backend/libs/todo/todo-persistence/src/index.ts @@ -1 +1,3 @@ -export * from './lib/todo-todo-persistence.module'; +export * from './lib/todo-persistence.module'; +export * from './lib/todo-persistence.service'; +export * from './lib/schema/todo.schema'; diff --git a/backend/libs/todo/todo-persistence/src/lib/schema/todo.schema.ts b/backend/libs/todo/todo-persistence/src/lib/schema/todo.schema.ts new file mode 100644 index 00000000..08a84550 --- /dev/null +++ b/backend/libs/todo/todo-persistence/src/lib/schema/todo.schema.ts @@ -0,0 +1,49 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document, Types } from 'mongoose'; + +export type SharedTodoListDocument = SharedTodoList & Document; + +class TodoItem { + @Prop({ required: true }) + id: string; + + @Prop({ required: true }) + title: string; + + @Prop({ required: false }) + done?: boolean; + + @Prop({ required: false }) + amount?: string; + + @Prop({ required: false }) + category?: string; +} + +@Schema({ collection: 'shared_todo_lists' }) +export class SharedTodoList { + _id?: Types.ObjectId; + + @Prop({ required: true }) + name!: string; + + @Prop({ required: true }) + emoji!: string; + + @Prop({ type: [TodoItem], required: true, default: [] }) + todos!: TodoItem[]; + + @Prop({ type: [String], required: false, default: [] }) + history?: string[]; + + @Prop({ type: Number, required: true, default: 1 }) + version!: number; + + @Prop({ type: Date, required: true, default: () => new Date() }) + created!: Date; + + @Prop({ type: Date, required: true, default: () => new Date() }) + updated!: Date; +} + +export const SharedTodoListSchema = SchemaFactory.createForClass(SharedTodoList); diff --git a/backend/libs/todo/todo-persistence/src/lib/todo-persistence.module.ts b/backend/libs/todo/todo-persistence/src/lib/todo-persistence.module.ts new file mode 100644 index 00000000..9a289f11 --- /dev/null +++ b/backend/libs/todo/todo-persistence/src/lib/todo-persistence.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { TodoPersistenceService } from './todo-persistence.service'; +import { SharedTodoList, SharedTodoListSchema } from './schema/todo.schema'; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: SharedTodoList.name, schema: SharedTodoListSchema }, + ]), + ], + controllers: [], + providers: [TodoPersistenceService], + exports: [TodoPersistenceService], +}) +export class TodoPersistenceModule {} diff --git a/backend/libs/todo/todo-persistence/src/lib/todo-persistence.service.spec.ts b/backend/libs/todo/todo-persistence/src/lib/todo-persistence.service.spec.ts new file mode 100644 index 00000000..0019cf59 --- /dev/null +++ b/backend/libs/todo/todo-persistence/src/lib/todo-persistence.service.spec.ts @@ -0,0 +1,333 @@ +import { ConflictException, NotFoundException } from '@nestjs/common'; +import { getModelToken } from '@nestjs/mongoose'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Model } from 'mongoose'; +import { SharedTodoList, SharedTodoListDocument } from './schema/todo.schema'; +import { TodoPersistenceService } from './todo-persistence.service'; + +describe('TodoPersistenceService', () => { + let service: TodoPersistenceService; + let model: Model; + + const mockTodoList = { + _id: 'mock-id-123', + name: 'Test List', + emoji: '๐Ÿ“', + todos: [ + { + id: 'todo-1', + title: 'Test Todo', + done: false, + amount: '1x', + category: 'Test', + }, + ], + history: ['Test Todo'], + version: 1, + created: new Date('2024-01-01'), + updated: new Date('2024-01-01'), + }; + + beforeEach(async () => { + const mockModel: any = jest.fn().mockImplementation((dto: any) => ({ + ...dto, + save: jest.fn().mockResolvedValue(dto), + })); + mockModel.find = jest.fn(); + mockModel.findById = jest.fn(); + mockModel.findOneAndUpdate = jest.fn(); + mockModel.findByIdAndDelete = jest.fn(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: getModelToken(SharedTodoList.name), + useValue: mockModel, + }, + TodoPersistenceService, + ], + }).compile(); + + service = module.get(TodoPersistenceService); + model = module.get>( + getModelToken(SharedTodoList.name), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined.', () => { + expect(service).toBeDefined(); + }); + + describe('findAll', () => { + it('should return all shared todo lists', async () => { + // arrange + const expectedLists = [ + mockTodoList, + { ...mockTodoList, _id: 'mock-id-456' }, + ]; + jest.spyOn(model, 'find').mockReturnValueOnce({ + exec: jest.fn().mockResolvedValueOnce(expectedLists), + } as any); + + // act & assert + await expect(service.findAll()).resolves.toEqual(expectedLists); + expect(model.find).toHaveBeenCalledWith(); + }); + + it('should return empty array when no lists found', async () => { + // arrange + jest.spyOn(model, 'find').mockReturnValueOnce({ + exec: jest.fn().mockResolvedValueOnce([]), + } as any); + + // act & assert + await expect(service.findAll()).resolves.toEqual([]); + }); + + it('should throw an error when finding all lists fails', async () => { + // arrange + const errorMsg = 'Failed to find lists'; + jest.spyOn(model, 'find').mockReturnValueOnce({ + exec: jest.fn().mockRejectedValueOnce(new Error(errorMsg)), + } as any); + + // act & assert + await expect(service.findAll()).rejects.toThrow(errorMsg); + }); + }); + + describe('findOne', () => { + it('should return a single shared todo list by id', async () => { + // arrange + const id = 'mock-id-123'; + jest.spyOn(model, 'findById').mockReturnValueOnce({ + exec: jest.fn().mockResolvedValueOnce(mockTodoList), + } as any); + + // act & assert + await expect(service.findOne(id)).resolves.toEqual(mockTodoList); + expect(model.findById).toHaveBeenCalledWith(id); + }); + + it('should return null when list not found', async () => { + // arrange + const id = 'non-existent-id'; + jest.spyOn(model, 'findById').mockReturnValueOnce({ + exec: jest.fn().mockResolvedValueOnce(null), + } as any); + + // act & assert + await expect(service.findOne(id)).resolves.toBeNull(); + }); + + it('should throw an error when finding by id fails', async () => { + // arrange + const id = 'mock-id-123'; + const errorMsg = 'Failed to find list'; + jest.spyOn(model, 'findById').mockReturnValueOnce({ + exec: jest.fn().mockRejectedValueOnce(new Error(errorMsg)), + } as any); + + // act & assert + await expect(service.findOne(id)).rejects.toThrow(errorMsg); + }); + }); + + describe('create', () => { + it('should create a new shared todo list', async () => { + // arrange + const name = 'New List'; + const emoji = 'โœจ'; + const newList = { + ...mockTodoList, + name, + emoji, + todos: [], + history: [], + version: 1, + }; + const saveMock = jest.fn().mockResolvedValueOnce(newList); + (model as any).mockImplementationOnce((dto: any) => ({ + ...dto, + save: saveMock, + })); + + // act + const result = await service.create(name, emoji); + + // assert + expect(result).toEqual(newList); + expect(saveMock).toHaveBeenCalled(); + }); + + it('should throw an error when creation fails', async () => { + // arrange + const name = 'New List'; + const emoji = 'โœจ'; + const errorMsg = 'Failed to create list'; + const saveMock = jest.fn().mockRejectedValueOnce(new Error(errorMsg)); + (model as any).mockImplementationOnce((dto: any) => ({ + ...dto, + save: saveMock, + })); + + // act & assert + await expect(service.create(name, emoji)).rejects.toThrow(errorMsg); + }); + }); + + describe('update', () => { + it('should update a shared todo list with optimistic locking', async () => { + // arrange + const id = 'mock-id-123'; + const name = 'Updated List'; + const emoji = '๐Ÿ”„'; + const todos = [{ id: 'todo-1', title: 'Updated Todo', done: true }]; + const history = ['Updated Todo']; + const currentVersion = 1; + const updatedList = { + ...mockTodoList, + name, + emoji, + todos, + history, + version: 2, + }; + + jest.spyOn(model, 'findOneAndUpdate').mockReturnValueOnce({ + exec: jest.fn().mockResolvedValueOnce(updatedList), + } as any); + + // act + const result = await service.update( + id, + name, + emoji, + todos, + history, + currentVersion, + ); + + // assert + expect(result).toEqual(updatedList); + expect(model.findOneAndUpdate).toHaveBeenCalledWith( + { _id: id, version: currentVersion }, + { + name, + emoji, + todos, + history, + version: currentVersion + 1, + updated: expect.any(Date), + }, + { new: true }, + ); + }); + + it('should throw NotFoundException when list does not exist', async () => { + // arrange + const id = 'non-existent-id'; + const name = 'Updated List'; + const emoji = '๐Ÿ”„'; + const todos: any[] = []; + const history: string[] = []; + const currentVersion = 1; + + jest.spyOn(model, 'findOneAndUpdate').mockReturnValueOnce({ + exec: jest.fn().mockResolvedValueOnce(null), + } as any); + jest.spyOn(model, 'findById').mockReturnValueOnce({ + exec: jest.fn().mockResolvedValueOnce(null), + } as any); + + // act & assert + await expect( + service.update(id, name, emoji, todos, history, currentVersion), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw ConflictException when version conflicts', async () => { + // arrange + const id = 'mock-id-123'; + const name = 'Updated List'; + const emoji = '๐Ÿ”„'; + const todos: any[] = []; + const history: string[] = []; + const currentVersion = 1; + const existingList = { ...mockTodoList, version: 2 }; + + jest.spyOn(model, 'findOneAndUpdate').mockReturnValueOnce({ + exec: jest.fn().mockResolvedValueOnce(null), + } as any); + jest.spyOn(model, 'findById').mockReturnValueOnce({ + exec: jest.fn().mockResolvedValueOnce(existingList), + } as any); + + // act & assert + await expect( + service.update(id, name, emoji, todos, history, currentVersion), + ).rejects.toThrow(ConflictException); + }); + + it('should throw an error when update operation fails', async () => { + // arrange + const id = 'mock-id-123'; + const name = 'Updated List'; + const emoji = '๐Ÿ”„'; + const todos: any[] = []; + const history: string[] = []; + const currentVersion = 1; + const errorMsg = 'Failed to update list'; + + jest.spyOn(model, 'findOneAndUpdate').mockReturnValueOnce({ + exec: jest.fn().mockRejectedValueOnce(new Error(errorMsg)), + } as any); + + // act & assert + await expect( + service.update(id, name, emoji, todos, history, currentVersion), + ).rejects.toThrow(errorMsg); + }); + }); + + describe('remove', () => { + it('should remove a shared todo list by id', async () => { + // arrange + const id = 'mock-id-123'; + jest.spyOn(model, 'findByIdAndDelete').mockReturnValueOnce({ + exec: jest.fn().mockResolvedValueOnce(mockTodoList), + } as any); + + // act & assert + await expect(service.remove(id)).resolves.toEqual(mockTodoList); + expect(model.findByIdAndDelete).toHaveBeenCalledWith(id); + }); + + it('should return null when list to remove is not found', async () => { + // arrange + const id = 'non-existent-id'; + jest.spyOn(model, 'findByIdAndDelete').mockReturnValueOnce({ + exec: jest.fn().mockResolvedValueOnce(null), + } as any); + + // act & assert + await expect(service.remove(id)).resolves.toBeNull(); + }); + + it('should throw an error when removal fails', async () => { + // arrange + const id = 'mock-id-123'; + const errorMsg = 'Failed to remove list'; + jest.spyOn(model, 'findByIdAndDelete').mockReturnValueOnce({ + exec: jest.fn().mockRejectedValueOnce(new Error(errorMsg)), + } as any); + + // act & assert + await expect(service.remove(id)).rejects.toThrow(errorMsg); + }); + }); +}); diff --git a/backend/libs/todo/todo-persistence/src/lib/todo-persistence.service.ts b/backend/libs/todo/todo-persistence/src/lib/todo-persistence.service.ts new file mode 100644 index 00000000..0875042e --- /dev/null +++ b/backend/libs/todo/todo-persistence/src/lib/todo-persistence.service.ts @@ -0,0 +1,89 @@ +import { Injectable, Logger, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { SharedTodoList, SharedTodoListDocument } from './schema/todo.schema'; + +@Injectable() +export class TodoPersistenceService { + private readonly logger = new Logger(TodoPersistenceService.name); + + constructor( + @InjectModel(SharedTodoList.name) + private sharedTodoListModel: Model, + ) {} + + async findAll(): Promise { + this.logger.log('Finding all shared todo lists'); + return this.sharedTodoListModel.find().exec(); + } + + async findOne(id: string): Promise { + this.logger.log(`Finding shared todo list with id: ${id}`); + return this.sharedTodoListModel.findById(id).exec(); + } + + async create( + name: string, + emoji: string, + ): Promise { + this.logger.log(`Creating new shared todo list: ${name}`); + const newList = new this.sharedTodoListModel({ + name, + emoji, + todos: [], + history: [], + version: 1, + }); + return newList.save(); + } + + async update( + id: string, + name: string, + emoji: string, + todos: any[], + history: string[], + currentVersion: number, + ): Promise { + this.logger.log(`Updating shared todo list with id: ${id}`); + + // Atomic update: include version check in the query filter to prevent TOCTOU race condition + const result = await this.sharedTodoListModel + .findOneAndUpdate( + { _id: id, version: currentVersion }, + { + name, + emoji, + todos, + history, + version: currentVersion + 1, + updated: new Date(), + }, + { new: true }, + ) + .exec(); + + // If no result, determine if it's a "not found" or "version conflict" + if (!result) { + const list = await this.sharedTodoListModel.findById(id).exec(); + if (!list) { + this.logger.warn(`Shared todo list not found: ${id}`); + throw new NotFoundException(`Shared todo list with id ${id} not found`); + } + // List exists but version didn't match - conflict + this.logger.warn( + `Version conflict for list ${id}. Expected: ${currentVersion}, Current: ${list.version}`, + ); + throw new ConflictException( + `Version conflict. Expected version ${currentVersion}, but current version is ${list.version}`, + ); + } + + return result; + } + + async remove(id: string): Promise { + this.logger.log(`Removing shared todo list with id: ${id}`); + return this.sharedTodoListModel.findByIdAndDelete(id).exec(); + } +} diff --git a/backend/libs/todo/todo-persistence/src/lib/todo-todo-persistence.module.ts b/backend/libs/todo/todo-persistence/src/lib/todo-todo-persistence.module.ts deleted file mode 100644 index e6e454ee..00000000 --- a/backend/libs/todo/todo-persistence/src/lib/todo-todo-persistence.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; - -@Module({ - controllers: [], - providers: [], - exports: [], -}) -export class TodoTodoPersistenceModule {} diff --git a/backend/libs/todo/todo-provider/project.json b/backend/libs/todo/todo-provider/project.json index a2449908..9eacb721 100644 --- a/backend/libs/todo/todo-provider/project.json +++ b/backend/libs/todo/todo-provider/project.json @@ -1,5 +1,5 @@ { - "name": "todo-todo-provider", + "name": "todo-provider", "$schema": "../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "libs/todo/todo-provider/src", "projectType": "library", diff --git a/backend/libs/todo/todo-provider/src/index.ts b/backend/libs/todo/todo-provider/src/index.ts index 58f2d07e..60dd7318 100644 --- a/backend/libs/todo/todo-provider/src/index.ts +++ b/backend/libs/todo/todo-provider/src/index.ts @@ -1 +1,2 @@ -export * from './lib/todo-todo-provider.module'; +export * from './lib/todo-provider.module'; +export * from './lib/todo-provider.service'; diff --git a/backend/libs/todo/todo-provider/src/lib/todo-provider.module.ts b/backend/libs/todo/todo-provider/src/lib/todo-provider.module.ts new file mode 100644 index 00000000..03ac6d31 --- /dev/null +++ b/backend/libs/todo/todo-provider/src/lib/todo-provider.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TodoProviderService } from './todo-provider.service'; +import { TodoPersistenceModule } from '@backend/todo-persistence'; + +@Module({ + imports: [TodoPersistenceModule], + controllers: [], + providers: [TodoProviderService], + exports: [TodoProviderService], +}) +export class TodoProviderModule {} diff --git a/backend/libs/todo/todo-provider/src/lib/todo-provider.service.spec.ts b/backend/libs/todo/todo-provider/src/lib/todo-provider.service.spec.ts new file mode 100644 index 00000000..b1d56192 --- /dev/null +++ b/backend/libs/todo/todo-provider/src/lib/todo-provider.service.spec.ts @@ -0,0 +1,335 @@ +import { + CreateSharedTodoListInputDto, + GetSharedTodoListInputDto, + RemoveSharedTodoListInputDto, + UpdateSharedTodoListInputDto, +} from '@backend/shared-types'; +import { TodoPersistenceService } from '@backend/todo-persistence'; +import { NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { TodoProviderService } from './todo-provider.service'; + +describe('TodoProviderService', () => { + let service: TodoProviderService; + let persistenceService: TodoPersistenceService; + + const mockTodoList = { + _id: 'mock-id-123', + name: 'Test List', + emoji: '๐Ÿ“', + todos: [ + { + id: 'todo-1', + title: 'Test Todo', + done: false, + amount: '1x', + category: 'Test', + }, + ], + history: ['Test Todo'], + version: 1, + created: new Date('2024-01-01'), + updated: new Date('2024-01-01'), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: TodoPersistenceService, + useValue: { + findAll: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + }, + }, + TodoProviderService, + ], + }).compile(); + + service = module.get(TodoProviderService); + persistenceService = module.get( + TodoPersistenceService, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined.', () => { + expect(service).toBeDefined(); + }); + + describe('listSharedTodoLists', () => { + it('should return a list of all shared todo lists', async () => { + // arrange + const expectedLists = [ + mockTodoList, + { ...mockTodoList, _id: 'mock-id-456' }, + ]; + jest + .spyOn(persistenceService, 'findAll') + .mockResolvedValue(expectedLists as any); + + // act + const result = await service.listSharedTodoLists(); + + // assert + expect(result).toEqual(expectedLists); + expect(persistenceService.findAll).toHaveBeenCalled(); + }); + + it('should pass filter to persistence service', async () => { + // arrange + const filter = { name: 'Test' }; + jest.spyOn(persistenceService, 'findAll').mockResolvedValue([]); + + // act + await service.listSharedTodoLists(filter); + + // assert + expect(persistenceService.findAll).toHaveBeenCalled(); + }); + + it('should throw an error if listing fails', async () => { + // arrange + const errorMsg = 'Failed to list todos'; + jest + .spyOn(persistenceService, 'findAll') + .mockRejectedValue(new Error(errorMsg)); + + // act & assert + await expect(service.listSharedTodoLists()).rejects.toThrow(errorMsg); + }); + }); + + describe('getSharedTodoList', () => { + it('should return a single shared todo list by id', async () => { + // arrange + const input: GetSharedTodoListInputDto = { id: 'mock-id-123' }; + jest + .spyOn(persistenceService, 'findOne') + .mockResolvedValue(mockTodoList as any); + + // act + const result = await service.getSharedTodoList(input); + + // assert + expect(result).toEqual(mockTodoList); + expect(persistenceService.findOne).toHaveBeenCalledWith(input.id); + }); + + it('should throw NotFoundException when list not found', async () => { + // arrange + const input: GetSharedTodoListInputDto = { id: 'non-existent-id' }; + jest.spyOn(persistenceService, 'findOne').mockResolvedValue(null); + + // act & assert + await expect(service.getSharedTodoList(input)).rejects.toThrow( + NotFoundException, + ); + await expect(service.getSharedTodoList(input)).rejects.toThrow( + `Shared todo list with id ${input.id} not found`, + ); + }); + + it('should throw an error if retrieval fails', async () => { + // arrange + const input: GetSharedTodoListInputDto = { id: 'mock-id-123' }; + const errorMsg = 'Failed to get todo'; + jest + .spyOn(persistenceService, 'findOne') + .mockRejectedValue(new Error(errorMsg)); + + // act & assert + await expect(service.getSharedTodoList(input)).rejects.toThrow(errorMsg); + }); + }); + + describe('createSharedTodoList', () => { + it('should create a new shared todo list', async () => { + // arrange + const input: CreateSharedTodoListInputDto = { + name: 'New List', + emoji: 'โœจ', + }; + const expectedList = { + ...mockTodoList, + name: input.name, + emoji: input.emoji, + todos: [], + history: [], + }; + jest + .spyOn(persistenceService, 'create') + .mockResolvedValue(expectedList as any); + + // act + const result = await service.createSharedTodoList(input); + + // assert + expect(result).toEqual(expectedList); + expect(persistenceService.create).toHaveBeenCalledWith( + input.name, + input.emoji, + ); + }); + + it('should throw an error if creation fails', async () => { + // arrange + const input: CreateSharedTodoListInputDto = { + name: 'New List', + emoji: 'โœจ', + }; + const errorMsg = 'Failed to create todo'; + jest + .spyOn(persistenceService, 'create') + .mockRejectedValue(new Error(errorMsg)); + + // act & assert + await expect(service.createSharedTodoList(input)).rejects.toThrow( + errorMsg, + ); + }); + }); + + describe('updateSharedTodoList', () => { + it('should update a shared todo list', async () => { + // arrange + const input: UpdateSharedTodoListInputDto = { + id: 'mock-id-123', + name: 'Updated List', + emoji: '๐Ÿ”„', + todos: [ + { + id: 'todo-1', + title: 'Updated Todo', + done: true, + amount: '2x', + category: 'Updated', + }, + ], + history: ['Updated Todo'], + version: 1, + }; + const expectedList = { + ...mockTodoList, + ...input, + version: 2, + }; + jest + .spyOn(persistenceService, 'update') + .mockResolvedValue(expectedList as any); + + // act + const result = await service.updateSharedTodoList(input); + + // assert + expect(result).toEqual(expectedList); + expect(persistenceService.update).toHaveBeenCalledWith( + input.id, + input.name, + input.emoji, + input.todos, + input.history, + input.version, + ); + }); + + it('should handle missing history field', async () => { + // arrange + const input: UpdateSharedTodoListInputDto = { + id: 'mock-id-123', + name: 'Updated List', + emoji: '๐Ÿ”„', + todos: [], + version: 1, + }; + jest + .spyOn(persistenceService, 'update') + .mockResolvedValue(mockTodoList as any); + + // act + await service.updateSharedTodoList(input); + + // assert + expect(persistenceService.update).toHaveBeenCalledWith( + input.id, + input.name, + input.emoji, + input.todos, + [], + input.version, + ); + }); + + it('should throw an error if update fails', async () => { + // arrange + const input: UpdateSharedTodoListInputDto = { + id: 'mock-id-123', + name: 'Updated List', + emoji: '๐Ÿ”„', + todos: [], + history: [], + version: 1, + }; + const errorMsg = 'Failed to update todo'; + jest + .spyOn(persistenceService, 'update') + .mockRejectedValue(new Error(errorMsg)); + + // act & assert + await expect(service.updateSharedTodoList(input)).rejects.toThrow( + errorMsg, + ); + }); + }); + + describe('removeSharedTodoList', () => { + it('should remove a shared todo list', async () => { + // arrange + const input: RemoveSharedTodoListInputDto = { id: 'mock-id-123' }; + jest + .spyOn(persistenceService, 'remove') + .mockResolvedValue(mockTodoList as any); + + // act + const result = await service.removeSharedTodoList(input); + + // assert + expect(result).toEqual(mockTodoList); + expect(persistenceService.remove).toHaveBeenCalledWith(input.id); + }); + + it('should throw NotFoundException when list to remove not found', async () => { + // arrange + const input: RemoveSharedTodoListInputDto = { id: 'non-existent-id' }; + jest.spyOn(persistenceService, 'remove').mockResolvedValue(null); + + // act & assert + await expect(service.removeSharedTodoList(input)).rejects.toThrow( + NotFoundException, + ); + await expect(service.removeSharedTodoList(input)).rejects.toThrow( + `Shared todo list with id ${input.id} not found`, + ); + }); + + it('should throw an error if removal fails', async () => { + // arrange + const input: RemoveSharedTodoListInputDto = { id: 'mock-id-123' }; + const errorMsg = 'Failed to remove todo'; + jest + .spyOn(persistenceService, 'remove') + .mockRejectedValue(new Error(errorMsg)); + + // act & assert + await expect(service.removeSharedTodoList(input)).rejects.toThrow( + errorMsg, + ); + }); + }); +}); diff --git a/backend/libs/todo/todo-provider/src/lib/todo-provider.service.ts b/backend/libs/todo/todo-provider/src/lib/todo-provider.service.ts new file mode 100644 index 00000000..ef7f4fe1 --- /dev/null +++ b/backend/libs/todo/todo-provider/src/lib/todo-provider.service.ts @@ -0,0 +1,103 @@ +import { + CreateSharedTodoListInputDto, + CreateSharedTodoListOutputDto, + GetSharedTodoListInputDto, + GetSharedTodoListOutputDto, + GetSharedTodoListsOutputDto, + RemoveSharedTodoListInputDto, + RemoveSharedTodoListOutputDto, + UpdateSharedTodoListInputDto, + UpdateSharedTodoListOutputDto, +} from '@backend/shared-types'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { FilterQuery } from 'mongoose'; +import { TodoPersistenceService, SharedTodoListDocument } from '@backend/todo-persistence'; + +@Injectable() +export class TodoProviderService { + private readonly logger = new Logger(TodoProviderService.name); + constructor(private todoPersistenceService: TodoPersistenceService) {} + + /** + * Fetches all shared todo lists. + * + * @param filter Optional param to filter for specific shared todo list results. + * @returns An array of shared todo list objects. + */ + async listSharedTodoLists( + filter: FilterQuery = {}, + ): Promise { + this.logger.log('Return a list of all shared todo lists.'); + return await this.todoPersistenceService.findAll() as any; + } + + /** + * Fetches a shared todo list by its id. + * + * @param getSharedTodoListInput The id of the shared todo list. + * @returns A single shared todo list object. + */ + async getSharedTodoList( + getSharedTodoListInput: GetSharedTodoListInputDto, + ): Promise { + this.logger.log('Returns a single shared todo list.'); + const result = await this.todoPersistenceService.findOne(getSharedTodoListInput.id); + if (!result) { + throw new NotFoundException(`Shared todo list with id ${getSharedTodoListInput.id} not found`); + } + return result as any; + } + + /** + * Creates a shared todo list. + * + * @param createSharedTodoListInputDto The name and emoji of the shared todo list. + * @returns The created shared todo list object. + */ + async createSharedTodoList( + createSharedTodoListInputDto: CreateSharedTodoListInputDto, + ): Promise { + this.logger.log('Creates a new shared todo list.'); + return await this.todoPersistenceService.create( + createSharedTodoListInputDto.name, + createSharedTodoListInputDto.emoji, + ) as any; + } + + /** + * Updates a shared todo list. + * + * @param updateSharedTodoListInputDto The shared todo list data to update. + * @returns The updated shared todo list object. + */ + async updateSharedTodoList( + updateSharedTodoListInputDto: UpdateSharedTodoListInputDto, + ): Promise { + this.logger.log('Updates a shared todo list.'); + return await this.todoPersistenceService.update( + updateSharedTodoListInputDto.id, + updateSharedTodoListInputDto.name, + updateSharedTodoListInputDto.emoji, + updateSharedTodoListInputDto.todos, + updateSharedTodoListInputDto.history || [], + updateSharedTodoListInputDto.version, + ) as any; + } + + /** + * Removes a shared todo list. + * + * @param removeSharedTodoListInputDto The id of the shared todo list to remove. + * @returns The removed shared todo list object. + */ + async removeSharedTodoList( + removeSharedTodoListInputDto: RemoveSharedTodoListInputDto, + ): Promise { + this.logger.log('Removes a shared todo list.'); + const result = await this.todoPersistenceService.remove(removeSharedTodoListInputDto.id); + if (!result) { + throw new NotFoundException(`Shared todo list with id ${removeSharedTodoListInputDto.id} not found`); + } + return result as any; + } +} diff --git a/backend/libs/todo/todo-provider/src/lib/todo-todo-provider.module.ts b/backend/libs/todo/todo-provider/src/lib/todo-todo-provider.module.ts deleted file mode 100644 index d79d2279..00000000 --- a/backend/libs/todo/todo-provider/src/lib/todo-todo-provider.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; - -@Module({ - controllers: [], - providers: [], - exports: [], -}) -export class TodoTodoProviderModule {} diff --git a/backend/tsconfig.base.json b/backend/tsconfig.base.json index a3e7b63c..5c0280c2 100644 --- a/backend/tsconfig.base.json +++ b/backend/tsconfig.base.json @@ -36,6 +36,10 @@ "@backend/shared-texts": ["libs/shared/common/texts/src/index.ts"], "@backend/shared-types": ["libs/shared/common/types/src/index.ts"], "@backend/todo": ["libs/todo/src/index.ts"], + "@backend/todo-controller": ["libs/todo/todo-controller/src/index.ts"], + "@backend/todo-persistence": ["libs/todo/todo-persistence/src/index.ts"], + "@backend/todo-provider": ["libs/todo/todo-provider/src/index.ts"], + "@backend/shared-todo": ["libs/shared/provider/todo/src/index.ts"], "@backend/util": ["libs/shared/util/src/index.ts"], "@backend/shared-identifiers": [ "libs/shared/provider/identifiers/src/index.ts" diff --git a/frontend/src/lib/api/todo.api.ts b/frontend/src/lib/api/todo.api.ts new file mode 100644 index 00000000..f3e4088a --- /dev/null +++ b/frontend/src/lib/api/todo.api.ts @@ -0,0 +1,64 @@ +import { dev } from '$app/environment'; +import type { SharedTodoListResponse } from '$lib/types/todo'; +import { environment } from '$lib/util/environment'; +import { createHeaders } from './helper'; + +const apiURL = dev + ? environment.localApiBaseUrl + : environment.productionApiBaseUrl; + +export const getSharedTodoList = async ( + sharedId: string, +): Promise<{ status: number; data: SharedTodoListResponse | null }> => { + const response = await fetch(`${apiURL}/shared-todo-lists/${sharedId}`); + + const responseData = response.ok ? await response.json() : null; + + return { + status: response.status, + data: responseData, + }; +}; + +export const createSharedTodoList = async ( + name: string, + emoji: string, +): Promise => { + return await fetch(`${apiURL}/shared-todo-lists`, { + method: 'POST', + headers: createHeaders(), + body: JSON.stringify({ name, emoji }), + }).then((res) => res.json()); +}; + +export const updateSharedTodoList = async ( + sharedId: string, + data: { + name: string; + emoji: string; + todos: any[]; + history: string[]; + version: number; + }, +): Promise<{ status: number; data: SharedTodoListResponse | null }> => { + const response = await fetch(`${apiURL}/shared-todo-lists/${sharedId}`, { + method: 'PUT', + headers: createHeaders(), + body: JSON.stringify({ ...data, id: sharedId }), + }); + + const responseData = response.ok ? await response.json() : null; + + return { + status: response.status, + data: responseData, + }; +}; + +export const deleteSharedTodoList = async ( + sharedId: string, +): Promise => { + return await fetch(`${apiURL}/shared-todo-lists/${sharedId}`, { + method: 'DELETE', + }).then((res) => res.json()); +}; diff --git a/frontend/src/lib/components/admin/Activities.svelte b/frontend/src/lib/components/admin/Activities.svelte index dc6a5985..f0b9c379 100644 --- a/frontend/src/lib/components/admin/Activities.svelte +++ b/frontend/src/lib/components/admin/Activities.svelte @@ -48,8 +48,9 @@ {#each activities as activity} + {@const Icon = getActivityTypeIcon(activity.type)}
- + {activity.description}
diff --git a/frontend/src/lib/components/admin/Identifiers.svelte b/frontend/src/lib/components/admin/Identifiers.svelte index 3e704b4b..f1f1ec0b 100644 --- a/frontend/src/lib/components/admin/Identifiers.svelte +++ b/frontend/src/lib/components/admin/Identifiers.svelte @@ -5,6 +5,7 @@ adminTokenStore, } from '$lib/util/stores/stores-admin'; import { initialized, t } from '$lib/util/translations'; + import { fade } from 'svelte/transition'; import Accordion from 'carbon-components-svelte/src/Accordion/Accordion.svelte'; import AccordionItem from 'carbon-components-svelte/src/Accordion/AccordionItem.svelte'; import Button from 'carbon-components-svelte/src/Button/Button.svelte'; diff --git a/frontend/src/lib/components/admin/Toggles.svelte b/frontend/src/lib/components/admin/Toggles.svelte index 315de202..d540513a 100644 --- a/frontend/src/lib/components/admin/Toggles.svelte +++ b/frontend/src/lib/components/admin/Toggles.svelte @@ -57,7 +57,7 @@ key, value: 'false', }); - dispatch('updateDashboard'); + onUpdateDashboard(); } else { console.log('Switching toggle to "true"...'); await updateKey({ @@ -65,7 +65,7 @@ key, value: 'true', }); - dispatch('updateDashboard'); + onUpdateDashboard(); } }; @@ -105,7 +105,7 @@
- + {toggle.key}
diff --git a/frontend/src/lib/components/food-scan/DebugInformation.svelte b/frontend/src/lib/components/food-scan/DebugInformation.svelte index a49b7902..d776bab4 100644 --- a/frontend/src/lib/components/food-scan/DebugInformation.svelte +++ b/frontend/src/lib/components/food-scan/DebugInformation.svelte @@ -11,7 +11,7 @@ // 3. PROPS let { - selectedModel, + selectedModel = $bindable(), availableModels, systemPromptText, userPromptText, diff --git a/frontend/src/lib/components/settings/BackgroundColorPicker.svelte b/frontend/src/lib/components/settings/BackgroundColorPicker.svelte index 1858ba7e..bb1a478f 100644 --- a/frontend/src/lib/components/settings/BackgroundColorPicker.svelte +++ b/frontend/src/lib/components/settings/BackgroundColorPicker.svelte @@ -102,10 +102,5 @@ display: flex; align-items: center; font-size: 0.9rem; - - span { - margin-left: 1rem; - margin-right: 1rem; - } } diff --git a/frontend/src/lib/components/settings/OnlinePersistenceCheck.svelte b/frontend/src/lib/components/settings/OnlinePersistenceCheck.svelte index 2437f17c..8067e7d1 100644 --- a/frontend/src/lib/components/settings/OnlinePersistenceCheck.svelte +++ b/frontend/src/lib/components/settings/OnlinePersistenceCheck.svelte @@ -282,11 +282,6 @@ margin-top: 1rem; } - .online_persistence_toggle { - display: flex; - flex-direction: row; - justify-content: center; - } .input_area { display: flex; diff --git a/frontend/src/lib/components/shared/ColorPicker.svelte b/frontend/src/lib/components/shared/ColorPicker.svelte index afe23077..e9d167c8 100644 --- a/frontend/src/lib/components/shared/ColorPicker.svelte +++ b/frontend/src/lib/components/shared/ColorPicker.svelte @@ -1,4 +1,4 @@ - + +{#if $initialized} +
+ + + {#if isOpen} +
+ + +
+ {#if filteredEmojis.length > 0} + {#each filteredEmojis as category} +
+

{category.name}

+
+ {#each category.emojis as emoji} + + {/each} +
+
+ {/each} + {:else} +
+

{$t('page.shared.noEmojisFound')}

+
+ {/if} +
+
+ {/if} +
+{:else} +
Loading...
+{/if} + + diff --git a/frontend/src/lib/components/shared/ToggledApplicationInfo.svelte b/frontend/src/lib/components/shared/ToggledApplicationInfo.svelte index 8ea6e2f6..a33a26e0 100644 --- a/frontend/src/lib/components/shared/ToggledApplicationInfo.svelte +++ b/frontend/src/lib/components/shared/ToggledApplicationInfo.svelte @@ -9,7 +9,7 @@ {#if $initialized}
- +

{$t('page.shared.toggledSiteHeadline')}

{$t('page.shared.toggledSiteTryAgainText')}

diff --git a/frontend/src/lib/components/todo/Todo.svelte b/frontend/src/lib/components/todo/Todo.svelte index 31025fa5..e61bba9e 100644 --- a/frontend/src/lib/components/todo/Todo.svelte +++ b/frontend/src/lib/components/todo/Todo.svelte @@ -1,42 +1,174 @@ -
- +
!isRenaming && e.key === 'Enter' && todoChecked()} +> + {#if isRenaming} +
+ { + if (e.key === 'Enter') { + e.stopPropagation(); + saveEdit(); + } + }} + /> + { + if (e.key === 'Enter') { + e.stopPropagation(); + saveEdit(); + } + }} + /> + { + if (e.key === 'Enter') { + e.stopPropagation(); + saveEdit(); + } + }} + /> +
+ {:else} + + {/if}
e.stopPropagation()} + onkeydown={(e) => e.key === 'Enter' && e.stopPropagation()} role="button" tabindex="-1" style="display: contents;" @@ -52,6 +184,15 @@
+ + + + diff --git a/frontend/src/lib/components/todo/TodoInput.svelte b/frontend/src/lib/components/todo/TodoInput.svelte index 0ae60261..a4d38046 100644 --- a/frontend/src/lib/components/todo/TodoInput.svelte +++ b/frontend/src/lib/components/todo/TodoInput.svelte @@ -2,14 +2,17 @@ // 1. IMPORTS import { todoStore } from '$lib/util/stores/store-todo'; import { initialized, t } from '$lib/util/translations'; + import Button from 'carbon-components-svelte/src/Button/Button.svelte'; + import TextInput from 'carbon-components-svelte/src/TextInput/TextInput.svelte'; import Add from 'carbon-icons-svelte/lib/Add.svelte'; - import InputWithButton from '../shared/custom-carbon-components/InputWithButton.svelte'; // 2. PROPS - let { listId }: { listId: string } = $props(); + let { listId, onTodoAdded }: { listId: string; onTodoAdded?: () => void } = $props(); // 4. STATE let newTodoName = $state(''); + let newTodoAmount = $state('1x'); + let newTodoCategory = $state(''); // 8. FUNCTIONS const saveTodo = () => { @@ -25,6 +28,8 @@ id: crypto.randomUUID(), title: newTodoName, done: false, + amount: newTodoAmount || '1x', + category: newTodoCategory || '', }, ], history: Array.from(new Set([...list.history, newTodoName])), @@ -34,23 +39,67 @@ }); }); newTodoName = ''; + newTodoAmount = '1x'; + newTodoCategory = ''; + } + if (onTodoAdded) { + onTodoAdded(); } }; {#if $initialized} -
- +
+
+
+ e.key === 'Enter' && saveTodo()} + /> +
+ e.key === 'Enter' && saveTodo()} + /> + e.key === 'Enter' && saveTodo()} + /> +
{:else}
Locale initializing...
{/if} + + diff --git a/frontend/src/lib/components/todo/TodoList.svelte b/frontend/src/lib/components/todo/TodoList.svelte index ed878045..6717bcc8 100644 --- a/frontend/src/lib/components/todo/TodoList.svelte +++ b/frontend/src/lib/components/todo/TodoList.svelte @@ -1,29 +1,50 @@ {#if $initialized}
+ {#if showConflictNotification} + (showConflictNotification = false)} + /> + {/if} + {#if showDeletedNotification} + (showDeletedNotification = false)} + /> + {/if}

@@ -179,19 +368,24 @@
{#each list.history as entry (entry)} - +
- + +
{/each}
- +
+ +
+

- {#each list?.todos || [] as todo (todo.id)} - deleteTodo(todo.id)} - todoChecked={() => checkTodo(todo.id)} - /> - {/each} + {#if !isCategoryView} + {#if Object.keys(list?.todos).length === 0} +
{$t('page.todos.list.emptyList')}
+ {:else} + {#each list?.todos || [] as todo (todo.id)} + deleteTodo(todo.id)} + todoChecked={() => checkTodo(todo.id)} + /> + {/each} + {/if} + {:else if Object.keys(categorizedTodos).length === 0} +
{$t('page.todos.list.emptyList')}
+ {:else} + {#each Object.entries(categorizedTodos) as [category, todos]} +
+

+ {category === 'Done' + ? $t('page.todos.doneCategory') + : category === '' + ? $t('page.todos.uncategorized') + : category} +

+ {#each todos as todo (todo.id)} + deleteTodo(todo.id)} + todoChecked={() => checkTodo(todo.id)} + /> + {/each} +
+ {/each} + {/if}
@@ -289,23 +526,65 @@ flex-wrap: wrap; } - .tag_button { - background: none; + hr { + width: 100%; + } + + .history_tag_wrapper { + display: inline-flex; + align-items: center; + gap: 0.25rem; + margin: 0.25rem; + background: #393939; + border-radius: 4px; + padding: 0.25rem 0.5rem; + transition: background-color 0.2s ease; + + &:hover { + background: #525252; + } + } + + .history_tag { + background: transparent; border: none; + color: white; + cursor: pointer; padding: 0; - margin: 0; - font: inherit; - color: inherit; + font-size: 0.875rem; + } + + .history_delete_btn { + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.6); cursor: pointer; - text-align: inherit; - text-decoration: none; - display: inline; - appearance: none; - -webkit-appearance: none; - -moz-appearance: none; + padding: 0; + font-size: 1rem; + line-height: 1; + transition: color 0.2s ease; + + &:hover { + color: #da1e28; + } } - hr { - width: 100%; + .view-toggle-container { + margin: 1rem 0; + display: flex; + justify-content: flex-end; + } + + .category-section { + margin-bottom: 1.5rem; + } + + .category-header { + color: rgba(255, 255, 255, 0.8); + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 0.5rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.2); } diff --git a/frontend/src/lib/components/todo/TodoListOverlay.svelte b/frontend/src/lib/components/todo/TodoListOverlay.svelte index a4add8d8..57313154 100644 --- a/frontend/src/lib/components/todo/TodoListOverlay.svelte +++ b/frontend/src/lib/components/todo/TodoListOverlay.svelte @@ -1,5 +1,10 @@ @@ -186,7 +190,7 @@
- + {@render children()}
diff --git a/frontend/src/routes/todo/+page.svelte b/frontend/src/routes/todo/+page.svelte index 2f86042f..6848be29 100644 --- a/frontend/src/routes/todo/+page.svelte +++ b/frontend/src/routes/todo/+page.svelte @@ -3,41 +3,117 @@ import ToggledApplicationInfo from '$lib/components/shared/ToggledApplicationInfo.svelte'; import TodoListComponent from '$lib/components/todo/TodoList.svelte'; import TodoListOverlay from '$lib/components/todo/TodoListOverlay.svelte'; + import { getSharedTodoList } from '$lib/api/todo.api'; import { applicationRoutes } from '$lib/config/applications'; + import type { TodoList } from '$lib/types/todo'; import { languageStore } from '$lib/util/stores/store-language'; import { listOverlayOptionsStore } from '$lib/util/stores/store-other'; import { todoStore } from '$lib/util/stores/store-todo'; import { initialized, setLocale, t } from '$lib/util/translations'; import Button from 'carbon-components-svelte/src/Button/Button.svelte'; + import InlineNotification from 'carbon-components-svelte/src/Notification/InlineNotification.svelte'; import Modal from 'carbon-components-svelte/src/Modal/Modal.svelte'; + import TextInput from 'carbon-components-svelte/src/TextInput/TextInput.svelte'; import ClickableTile from 'carbon-components-svelte/src/Tile/ClickableTile.svelte'; import Catalog from 'carbon-icons-svelte/lib/Catalog.svelte'; + import DocumentDownload from 'carbon-icons-svelte/lib/DocumentDownload.svelte'; import Edit from 'carbon-icons-svelte/lib/Edit.svelte'; import TaskAdd from 'carbon-icons-svelte/lib/TaskAdd.svelte'; import { onMount } from 'svelte'; // 2. CONST (non-reactive constants) const { todo: todoRoute } = applicationRoutes; + const LAST_VIEWED_LIST_KEY = 'tilloh-dev:todo:lastViewedListId'; // 4. STATE let currentListId = $state(''); let newListId = $state(''); let openMenu = $state(false); let locale = $state($languageStore); + let showImportModal = $state(false); + let importSharedId = $state(''); + let importErrorMessage = $state(''); + let showImportError = $state(false); // 6. EFFECTS $effect(() => { locale = $languageStore; }); + // Persist last viewed list ID to localStorage + $effect(() => { + if (currentListId) { + localStorage.setItem(LAST_VIEWED_LIST_KEY, currentListId); + } + }); + + // Global polling for all shared lists to detect deletions + $effect(() => { + const sharedLists = $todoStore.filter((list) => list.isShared && list.sharedId); + if (sharedLists.length === 0) return; + + const checkAllSharedLists = async () => { + const listsToRemove: string[] = []; + + for (const list of sharedLists) { + try { + const result = await getSharedTodoList(list.sharedId!); + if (result.status === 404) { + listsToRemove.push(list.id); + } + } catch (error) { + console.error('Error checking shared list:', list.name, error); + } + } + + if (listsToRemove.length > 0) { + console.log('Removing deleted shared lists:', listsToRemove.length); + todoStore.update((lists) => lists.filter((list) => !listsToRemove.includes(list.id))); + + // If current list was deleted, select another + if (listsToRemove.includes(currentListId)) { + window.dispatchEvent(new CustomEvent('list-deleted')); + } + } + }; + + // Check immediately + checkAllSharedLists(); + + // Then check every 10 seconds + const interval = setInterval(checkAllSharedLists, 10000); + + return () => clearInterval(interval); + }); + // 7. LIFECYCLE onMount(async () => { await setLocale($languageStore); + // Restore last viewed list from localStorage + const savedListId = localStorage.getItem(LAST_VIEWED_LIST_KEY); + if (savedListId && $todoStore.some((list) => list.id === savedListId)) { + currentListId = savedListId; + } else if ($todoStore.length > 0) { + // Fallback to first list if saved ID doesn't exist + currentListId = $todoStore[0].id; + } + // Listen for list creation events window.addEventListener('list-created', ((e: CustomEvent) => { currentListId = e.detail.id; }) as EventListener); + + // Listen for list deletion events + window.addEventListener('list-deleted', () => { + // Set currentListId to first remaining list, or empty if none exist + if ($todoStore.length > 0) { + currentListId = $todoStore[0].id; + } else { + currentListId = ''; + localStorage.removeItem(LAST_VIEWED_LIST_KEY); + } + }); }); // 8. FUNCTIONS @@ -53,6 +129,57 @@ $listOverlayOptionsStore.type = type; } }; + + const importSharedList = async () => { + if (!importSharedId.trim()) return; + + try { + // Check for duplicates + const existingList = $todoStore.find((list) => list.sharedId === importSharedId); + if (existingList) { + importErrorMessage = $t('page.todos.share.importDuplicate'); + showImportError = true; + setTimeout(() => { + showImportError = false; + }, 5000); + return; + } + + const result = await getSharedTodoList(importSharedId); + + if (result.status !== 200 || !result.data) { + importErrorMessage = $t('page.todos.share.importError'); + showImportError = true; + setTimeout(() => { + showImportError = false; + }, 5000); + return; + } + + const sharedList = result.data; + const newList: TodoList = { + id: crypto.randomUUID(), + name: sharedList.name, + emoji: sharedList.emoji, + todos: sharedList.todos, + history: sharedList.history || [], + isShared: true, + sharedId: sharedList._id, + version: sharedList.version, + }; + + todoStore.update((n) => [...n, newList]); + importSharedId = ''; + showImportModal = false; + } catch (error) { + console.error('Error importing shared list:', error); + importErrorMessage = $t('page.todos.share.importError'); + showImportError = true; + setTimeout(() => { + showImportError = false; + }, 5000); + } + }; const setActiveList = (id: string) => { currentListId = id; openMenu = false; @@ -98,6 +225,12 @@

{list.emoji} {list.name} + {#if list.isShared} + ๐Ÿ”— + {/if}

+
+ (showImportModal = false)} + > + {#if $initialized} +

{$t('page.todos.share.importDescription')}

+ e.key === 'Enter' && importSharedList()} + /> + {:else} +

Locale initializing...

+ {/if} +
+
+ {#if showImportError} + (showImportError = false)} + /> + {/if}