diff --git a/listify-api/src/lists/lists.controller.ts b/listify-api/src/lists/lists.controller.ts index 7b90242..3ebae39 100644 --- a/listify-api/src/lists/lists.controller.ts +++ b/listify-api/src/lists/lists.controller.ts @@ -40,6 +40,17 @@ export class ListsController { return this.listsService.createList(this.requireUserId(request), createDto); } + @Post('with-item-suggestions') + createListWithItemSuggestions( + @Req() request: AuthenticatedRequest, + @Body() createDto: CreateListDto, + ) { + return this.listsService.createListWithItemSuggestions( + this.requireUserId(request), + createDto, + ); + } + @Get() listLists(@Req() request: AuthenticatedRequest) { return this.listsService.listLists(this.requireUserId(request)); diff --git a/listify-api/src/lists/lists.service.spec.ts b/listify-api/src/lists/lists.service.spec.ts index 5688131..a981069 100644 --- a/listify-api/src/lists/lists.service.spec.ts +++ b/listify-api/src/lists/lists.service.spec.ts @@ -465,6 +465,55 @@ describe('ListsService', () => { expect(requestPayload.tools).toBeUndefined(); }); + it('creates a list and returns item suggestions for it', async () => { + process.env.MISTRAL_API_KEY = 'test-key'; + process.env.MISTRAL_AGENT_ID = 'agent-listify'; + mockMistralResponse({ + choices: [ + { + message: { + content: JSON.stringify({ + suggestions: [ + { + title: 'Location buchen', + notes: 'Mit Kapazitaet abgleichen', + required: true, + }, + ], + }), + }, + }, + ], + }); + + const response = await service.createListWithItemSuggestions('user-1', { + name: 'Sommerfest', + description: 'Planung fuer Team-Event', + kind: 'todo', + }); + + expect(response.list.name).toBe('Sommerfest'); + expect(response.list.items).toHaveLength(0); + expect(response.suggestions).toEqual([ + { + title: 'Location buchen', + notes: 'Mit Kapazitaet abgleichen', + quantity: undefined, + required: true, + }, + ]); + await expect(service.listLists('user-1')).resolves.toHaveLength(1); + + const requestPayload = getMistralRequestPayload(); + expect(requestPayload.messages[1].content).toContain('Name: Sommerfest'); + expect(requestPayload.messages[1].content).toContain( + 'Beschreibung: Planung fuer Team-Event', + ); + expect(requestPayload.messages[1].content).toContain( + 'Vorhandene Items: keine', + ); + }); + it('returns an empty suggestion list for malformed provider content', async () => { process.env.MISTRAL_API_KEY = 'test-key'; process.env.MISTRAL_AGENT_ID = 'agent-listify'; diff --git a/listify-api/src/lists/lists.service.ts b/listify-api/src/lists/lists.service.ts index 84eae23..0b54665 100644 --- a/listify-api/src/lists/lists.service.ts +++ b/listify-api/src/lists/lists.service.ts @@ -41,6 +41,11 @@ export interface ListItemSuggestionsResponse { suggestions: ListItemSuggestion[]; } +export interface CreateListWithItemSuggestionsResponse { + list: UserList; + suggestions: ListItemSuggestion[]; +} + interface MistralAgentCompletionResponse { choices?: Array<{ message?: { @@ -109,6 +114,21 @@ export class ListsService { return userList; } + async createListWithItemSuggestions( + ownerId: string, + createDto: CreateListDto, + ): Promise { + const list = await this.createList(ownerId, createDto); + const listEntity = await this.findAccessibleList(ownerId, list.id); + const response = await this.callMistralForItemSuggestions(listEntity); + const suggestions = this.normalizeItemSuggestions(response, listEntity.items); + + return { + list, + suggestions, + }; + } + async createListFromTemplate( ownerId: string, template: ListTemplate, diff --git a/listify-client/src/app/lists/list-detail/list-detail.component.html b/listify-client/src/app/lists/list-detail/list-detail.component.html index 422930f..d0cbc2a 100644 --- a/listify-client/src/app/lists/list-detail/list-detail.component.html +++ b/listify-client/src/app/lists/list-detail/list-detail.component.html @@ -63,7 +63,9 @@ @if (canDeleteList()) { - } @@ -95,14 +99,32 @@ - + + @if (isCreateMode()) { + } - {{ isCreateMode() ? 'Liste anlegen' : 'Speichern' }} - + } @else {
diff --git a/listify-client/src/app/lists/list-detail/list-detail.component.scss b/listify-client/src/app/lists/list-detail/list-detail.component.scss index 5577b9e..2880013 100644 --- a/listify-client/src/app/lists/list-detail/list-detail.component.scss +++ b/listify-client/src/app/lists/list-detail/list-detail.component.scss @@ -2,11 +2,9 @@ display: grid; gap: 1rem; width: min(100%, 760px); - max-width: 100%; min-height: calc(100dvh - 56px); margin: 0 auto; padding: 1rem; - overflow-x: hidden; } .detail-header { @@ -14,7 +12,6 @@ grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 0.75rem; - min-width: 0; padding: 0.75rem 0 0.25rem; } @@ -37,7 +34,6 @@ .sharing-card, .items-card { min-width: 0; - max-width: 100%; overflow: hidden; border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent); border-radius: 8px; @@ -51,8 +47,10 @@ gap: 0.75rem; } -.share-search-field { - width: 100%; +.detail-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; } .share-results, @@ -108,10 +106,6 @@ margin-right: 0.5rem; } -.editor-card mat-card-header button { - flex: 0 0 auto; -} - .list-form, .item-form { display: grid; @@ -126,7 +120,12 @@ width: 100%; } -.list-form button[type='submit'], +.create-actions { + display: grid; + gap: 0.6rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + .item-form button[type='submit'] { min-height: 48px; } @@ -236,10 +235,6 @@ color: var(--mat-sys-on-surface-variant); } -.secondary-back { - justify-self: start; -} - @media (min-width: 701px) { .list-detail-page { gap: 1.25rem; @@ -256,3 +251,9 @@ align-items: center; } } + +@media (max-width: 520px) { + .create-actions { + grid-template-columns: 1fr; + } +} diff --git a/listify-client/src/app/lists/list-detail/list-detail.component.ts b/listify-client/src/app/lists/list-detail/list-detail.component.ts index 06b110b..223bd4e 100644 --- a/listify-client/src/app/lists/list-detail/list-detail.component.ts +++ b/listify-client/src/app/lists/list-detail/list-detail.component.ts @@ -64,6 +64,7 @@ export class ListDetailComponent implements OnInit { protected readonly isCreateMode = signal(false); protected readonly loading = signal(true); protected readonly saving = signal(false); + protected readonly creatingWithAi = signal(false); protected readonly creatingTemplate = signal(false); protected readonly deletingList = signal(false); protected readonly editing = signal(false); @@ -163,11 +164,7 @@ export class ListDetailComponent implements OnInit { return; } - const formValue = this.listForm.getRawValue(); - const payload = { - name: formValue.name.trim(), - description: formValue.description.trim() || undefined, - }; + const payload = this.listFormPayload(); const saveRequest = this.isCreateMode() || !listId ? this.listsService.createList(payload) @@ -193,8 +190,43 @@ export class ListDetailComponent implements OnInit { }); } + protected createListWithAi(): void { + if (!this.isCreateMode() || this.listForm.invalid || this.creatingWithAi()) { + this.listForm.markAllAsTouched(); + return; + } + + this.creatingWithAi.set(true); + this.suggestionsLoaded.set(false); + this.itemSuggestions.set([]); + + this.listsService + .createListWithItemSuggestions(this.listFormPayload()) + .pipe(finalize(() => this.creatingWithAi.set(false))) + .subscribe({ + next: (response) => { + this.setList(response.list); + this.itemSuggestions.set(response.suggestions); + this.suggestionsLoaded.set(true); + this.isCreateMode.set(false); + this.editing.set(true); + this.snackBar.open('Liste mit KI erstellt.', 'OK', { + duration: 2500, + }); + void this.router.navigate(['/lists', response.list.id], { + replaceUrl: true, + }); + }, + error: (error: unknown) => { + this.snackBar.open(getAuthErrorMessage(error), 'OK', { + duration: 5000, + }); + }, + }); + } + protected addItem(): void { - const listId = this.listId(); + const listId = this.currentListId(); if (!listId || !this.canEditItems() || this.itemForm.invalid) { this.itemForm.markAllAsTouched(); @@ -286,7 +318,7 @@ export class ListDetailComponent implements OnInit { } protected loadSuggestions(): void { - const listId = this.listId(); + const listId = this.currentListId(); if (!listId || !this.canEditItems() || this.loadingSuggestions()) { return; @@ -310,7 +342,7 @@ export class ListDetailComponent implements OnInit { } protected addSuggestion(suggestion: ListItemSuggestion): void { - const listId = this.listId(); + const listId = this.currentListId(); if (!listId || this.addingSuggestionTitle()) { return; @@ -343,7 +375,7 @@ export class ListDetailComponent implements OnInit { } protected toggleItem(item: UserListItem, checked: boolean): void { - const listId = this.listId(); + const listId = this.currentListId(); const currentList = this.list(); if (!listId || !currentList || this.updatingItemId()) { @@ -520,6 +552,19 @@ export class ListDetailComponent implements OnInit { return this.route.snapshot.paramMap.get('listId'); } + private currentListId(): string | null { + return this.listId() ?? this.list()?.id ?? null; + } + + private listFormPayload() { + const formValue = this.listForm.getRawValue(); + + return { + name: formValue.name.trim(), + description: formValue.description.trim() || undefined, + }; + } + private uncheckedFirst(items: UserListItem[]): UserListItem[] { return items .map((item, index) => ({ item, index })) diff --git a/listify-client/src/app/lists/lists.component.html b/listify-client/src/app/lists/lists.component.html index f53dfc7..1c00f4d 100644 --- a/listify-client/src/app/lists/lists.component.html +++ b/listify-client/src/app/lists/lists.component.html @@ -167,23 +167,6 @@ - @if (list.accessRole === 'owner') { - - } - ([]); protected readonly loading = signal(true); - protected readonly deletingListId = signal(null); protected readonly errorMessage = signal(null); protected readonly searchTerm = signal(''); protected readonly kindFilter = signal('all'); @@ -180,47 +171,6 @@ export class ListsComponent implements OnInit { this.sortOption.set('updated-desc'); } - protected deleteList(list: UserList): void { - if (list.accessRole !== 'owner' || this.deletingListId()) { - return; - } - - this.dialog - .open( - ConfirmDeleteListDialogComponent, - { - data: { listName: list.name }, - maxWidth: '420px', - width: 'calc(100vw - 32px)', - }, - ) - .afterClosed() - .subscribe((confirmed) => { - if (!confirmed) { - return; - } - - this.deletingListId.set(list.id); - - this.listsService - .deleteList(list.id) - .pipe(finalize(() => this.deletingListId.set(null))) - .subscribe({ - next: () => { - this.lists.update((lists) => - lists.filter((existingList) => existingList.id !== list.id), - ); - this.snackBar.open('Liste geloescht.', 'OK', { duration: 3000 }); - }, - error: (error: unknown) => { - this.snackBar.open(getAuthErrorMessage(error), 'OK', { - duration: 5000, - }); - }, - }); - }); - } - private subscribeToRealtime(): void { this.listsRealtimeService .events() diff --git a/listify-client/src/app/lists/lists.models.ts b/listify-client/src/app/lists/lists.models.ts index ecff155..c47c8ae 100644 --- a/listify-client/src/app/lists/lists.models.ts +++ b/listify-client/src/app/lists/lists.models.ts @@ -71,6 +71,11 @@ export interface ListItemSuggestionsResponse { suggestions: ListItemSuggestion[]; } +export interface CreateListWithItemSuggestionsResponse { + list: UserList; + suggestions: ListItemSuggestion[]; +} + export interface UpdateListItemRequest { title?: string; notes?: string; diff --git a/listify-client/src/app/lists/lists.service.ts b/listify-client/src/app/lists/lists.service.ts index e397f59..3f3a868 100644 --- a/listify-client/src/app/lists/lists.service.ts +++ b/listify-client/src/app/lists/lists.service.ts @@ -5,6 +5,7 @@ import { of, tap, throwError } from 'rxjs'; import { AddListItemRequest, CreateListRequest, + CreateListWithItemSuggestionsResponse, ListItemSuggestionsResponse, UpdateListItemRequest, UpdateListRequest, @@ -59,6 +60,23 @@ export class ListsService { return this.http.post(this.apiUrl, data); } + createListWithItemSuggestions( + data: CreateListRequest, + ): Observable { + if (!this.onlineStatus.online()) { + return throwError( + () => new Error('Listen mit KI koennen nur online erstellt werden.'), + ); + } + + return this.http + .post( + `${this.apiUrl}/with-item-suggestions`, + data, + ) + .pipe(tap((response) => this.upsertCachedList(response.list))); + } + updateList(listId: string, data: UpdateListRequest): Observable { if (!this.onlineStatus.online()) { const updatedList = this.updateCachedList(listId, (list) => ({