Vorschläge

This commit is contained in:
Bastian Wagner
2026-06-15 09:44:59 +02:00
parent 22c93f9ca1
commit cb938d3dc8
12 changed files with 681 additions and 13 deletions

View File

@@ -88,7 +88,9 @@ describe('AssistantChatComponent', () => {
});
it('falls back to route context when list context loading fails', async () => {
listsService.getList.mockReturnValue(throwError(() => new Error('not found')));
listsService.getList.mockReturnValue(
throwError(() => new Error('not found')),
);
await router.navigateByUrl('/lists/list-1');
const fixture = TestBed.createComponent(AssistantChatComponent);
const component = fixture.componentInstance as unknown as {

View File

@@ -214,6 +214,75 @@
Hinzufügen
</button>
</form>
@if (canEditItems()) {
<section class="suggestions-panel" aria-label="Smart Suggestions">
<div class="suggestions-header">
<div>
<h3>Smart Suggestions</h3>
<p>Passende Items basierend auf Name, Beschreibung und Inhalt.</p>
</div>
<button
mat-stroked-button
type="button"
[disabled]="loadingSuggestions()"
(click)="loadSuggestions()"
>
@if (loadingSuggestions()) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">auto_awesome</mat-icon>
}
Vorschlaege
</button>
</div>
@if (itemSuggestions().length > 0) {
<ul class="suggestion-list">
@for (suggestion of itemSuggestions(); track suggestion.title) {
<li>
<div>
<strong>{{ suggestion.title }}</strong>
@if (suggestion.notes || suggestion.quantity || !suggestion.required) {
<span>
@if (suggestion.quantity) {
Menge: {{ suggestion.quantity }}
}
@if (suggestion.notes) {
{{ suggestion.quantity ? '- ' : '' }}{{ suggestion.notes }}
}
@if (!suggestion.required) {
{{ suggestion.quantity || suggestion.notes ? '- ' : '' }}Optional
}
</span>
}
</div>
<button
mat-icon-button
type="button"
[attr.aria-label]="suggestion.title + ' hinzufuegen'"
[disabled]="addingSuggestionTitle() === suggestion.title"
(click)="addSuggestion(suggestion)"
>
@if (addingSuggestionTitle() === suggestion.title) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">add</mat-icon>
}
</button>
</li>
}
</ul>
} @else if (suggestionsLoaded()) {
<div class="inline-empty">
<mat-icon aria-hidden="true">tips_and_updates</mat-icon>
<span>Keine neuen Vorschlaege gefunden.</span>
</div>
}
</section>
}
}
@if (!canEditItems()) {
@@ -223,7 +292,7 @@
</div>
} @else if (list()?.items?.length) {
<ul class="check-items">
@for (item of list()!.items; track item.id) {
@for (item of visibleItems(list()!); track item.id) {
<li [class.checked]="item.checked">
<mat-checkbox
[checked]="item.checked"

View File

@@ -56,7 +56,8 @@
}
.share-results,
.collaborator-list {
.collaborator-list,
.suggestion-list {
display: grid;
gap: 0.5rem;
margin: 0.75rem 0 0;
@@ -65,7 +66,8 @@
}
.share-results li,
.collaborator-list li {
.collaborator-list li,
.suggestion-list li {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
@@ -79,24 +81,29 @@
.share-results span,
.collaborator-list strong,
.collaborator-list span {
.collaborator-list span,
.suggestion-list strong,
.suggestion-list span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.collaborator-list div {
.collaborator-list div,
.suggestion-list div {
display: grid;
min-width: 0;
}
.collaborator-list span {
.collaborator-list span,
.suggestion-list span {
color: var(--mat-sys-on-surface-variant);
font-size: 0.85rem;
}
.share-results mat-progress-spinner,
.collaborator-list mat-progress-spinner {
.collaborator-list mat-progress-spinner,
.suggestion-list mat-progress-spinner {
display: inline-flex;
margin-right: 0.5rem;
}
@@ -130,6 +137,23 @@
margin-right: 0.5rem;
}
.suggestions-panel {
display: grid;
gap: 0.75rem;
margin-top: 0.9rem;
}
.suggestions-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.75rem;
}
.suggestions-header h3 {
margin: 0;
}
.state-card mat-card-content {
display: grid;
justify-items: center;

View File

@@ -16,7 +16,12 @@ import { AuthService } from '../../auth/auth.service';
import { getAuthErrorMessage } from '../../auth/error-message';
import { PublicUserSearchResult } from '../../auth/auth.models';
import { OnboardingService } from '../../onboarding/onboarding.service';
import { ListRealtimeEvent, UserList, UserListItem } from '../lists.models';
import {
ListItemSuggestion,
ListRealtimeEvent,
UserList,
UserListItem,
} from '../lists.models';
import { ListsRealtimeService } from '../lists-realtime.service';
import { ListsService } from '../lists.service';
@@ -55,8 +60,12 @@ export class ListDetailComponent implements OnInit {
protected readonly saving = signal(false);
protected readonly editing = signal(false);
protected readonly addingItem = signal(false);
protected readonly loadingSuggestions = signal(false);
protected readonly suggestionsLoaded = signal(false);
protected readonly errorMessage = signal<string | null>(null);
protected readonly updatingItemId = signal<string | null>(null);
protected readonly addingSuggestionTitle = signal<string | null>(null);
protected readonly itemSuggestions = signal<ListItemSuggestion[]>([]);
protected readonly shareSearchTerm = signal('');
protected readonly shareSearchResults = signal<PublicUserSearchResult[]>([]);
protected readonly searchingUsers = signal(false);
@@ -201,6 +210,63 @@ export class ListDetailComponent implements OnInit {
});
}
protected loadSuggestions(): void {
const listId = this.listId();
if (!listId || !this.canEditItems() || this.loadingSuggestions()) {
return;
}
this.loadingSuggestions.set(true);
this.suggestionsLoaded.set(false);
this.listsService
.suggestItems(listId)
.pipe(finalize(() => this.loadingSuggestions.set(false)))
.subscribe({
next: (response) => {
this.itemSuggestions.set(response.suggestions);
this.suggestionsLoaded.set(true);
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
protected addSuggestion(suggestion: ListItemSuggestion): void {
const listId = this.listId();
if (!listId || this.addingSuggestionTitle()) {
return;
}
this.addingSuggestionTitle.set(suggestion.title);
this.listsService
.addItem(listId, {
title: suggestion.title,
notes: suggestion.notes,
quantity: suggestion.quantity,
required: suggestion.required,
})
.pipe(finalize(() => this.addingSuggestionTitle.set(null)))
.subscribe({
next: (list) => {
this.setList(list);
this.itemSuggestions.update((suggestions) =>
suggestions.filter(
(itemSuggestion) =>
this.suggestionKey(itemSuggestion.title) !==
this.suggestionKey(suggestion.title),
),
);
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
protected toggleItem(item: UserListItem, checked: boolean): void {
const listId = this.listId();
const currentList = this.list();
@@ -258,6 +324,10 @@ export class ListDetailComponent implements OnInit {
return list.items.filter((item) => item.checked).length;
}
protected visibleItems(list: UserList): UserListItem[] {
return this.uncheckedFirst(list.items);
}
protected searchShareUsers(term: string): void {
this.shareSearchTerm.set(term);
@@ -374,4 +444,21 @@ export class ListDetailComponent implements OnInit {
private listId(): string | null {
return this.route.snapshot.paramMap.get('listId');
}
private uncheckedFirst(items: UserListItem[]): UserListItem[] {
return items
.map((item, index) => ({ item, index }))
.sort((a, b) => {
if (a.item.checked !== b.item.checked) {
return a.item.checked ? 1 : -1;
}
return a.index - b.index;
})
.map(({ item }) => item);
}
private suggestionKey(value: string): string {
return value.trim().replace(/\s+/g, ' ').toLowerCase();
}
}

View File

@@ -154,7 +154,7 @@
@if (list.items.length > 0) {
<ul class="template-items">
@for (item of list.items.slice(0, 4); track item.id) {
@for (item of previewItems(list); track item.id) {
<li>
<mat-icon aria-hidden="true">
{{ item.checked ? 'check_circle' : 'radio_button_unchecked' }}

View File

@@ -13,7 +13,7 @@ import { MatSelectModule } from '@angular/material/select';
import { getAuthErrorMessage } from '../auth/error-message';
import { OnboardingService } from '../onboarding/onboarding.service';
import { ListTemplateKind } from '../templates/templates.models';
import { ListRealtimeEvent, UserList } from './lists.models';
import { ListRealtimeEvent, UserList, UserListItem } from './lists.models';
import { ListsRealtimeService } from './lists-realtime.service';
import { ListsService } from './lists.service';
@@ -152,6 +152,10 @@ export class ListsComponent implements OnInit {
return list.items.filter((item) => item.checked).length;
}
protected previewItems(list: UserList): UserListItem[] {
return this.uncheckedFirst(list.items).slice(0, 4);
}
protected progressLabel(list: UserList): string {
if (list.items.length === 0) {
return '0%';
@@ -230,4 +234,17 @@ export class ListsComponent implements OnInit {
private dateValue(value: string): number {
return new Date(value).getTime();
}
private uncheckedFirst(items: UserListItem[]): UserListItem[] {
return items
.map((item, index) => ({ item, index }))
.sort((a, b) => {
if (a.item.checked !== b.item.checked) {
return a.item.checked ? 1 : -1;
}
return a.index - b.index;
})
.map(({ item }) => item);
}
}

View File

@@ -60,6 +60,17 @@ export interface AddListItemRequest {
required?: boolean;
}
export interface ListItemSuggestion {
title: string;
notes?: string;
quantity?: number;
required: boolean;
}
export interface ListItemSuggestionsResponse {
suggestions: ListItemSuggestion[];
}
export interface UpdateListItemRequest {
title?: string;
notes?: string;

View File

@@ -4,6 +4,7 @@ import { Observable } from 'rxjs';
import {
AddListItemRequest,
CreateListRequest,
ListItemSuggestionsResponse,
UpdateListItemRequest,
UpdateListRequest,
UserList,
@@ -42,6 +43,13 @@ export class ListsService {
return this.http.post<UserList>(`${this.apiUrl}/${listId}/items`, data);
}
suggestItems(listId: string): Observable<ListItemSuggestionsResponse> {
return this.http.post<ListItemSuggestionsResponse>(
`${this.apiUrl}/${listId}/item-suggestions`,
{},
);
}
updateItem(
listId: string,
itemId: string,