added item expanding for search stats

This commit is contained in:
Flaminel
2026-03-24 10:28:19 +02:00
parent 8ad0fa3b3d
commit fb6e4ea662
5 changed files with 244 additions and 3 deletions

View File

@@ -18,6 +18,13 @@ export class SearchStatsApi {
return this.http.get<PaginatedResult<SearchHistoryEntry>>('/api/seeker/search-stats/history', { params });
}
getItemDetail(instanceId: string, itemId: number, seasonNumber = 0): Observable<{ entries: SearchEvent[] }> {
return this.http.get<{ entries: SearchEvent[] }>(
`/api/seeker/search-stats/history/${instanceId}/${itemId}/detail`,
{ params: { seasonNumber } },
);
}
getEvents(page = 1, pageSize = 50, instanceId?: string, cycleRunId?: string): Observable<PaginatedResult<SearchEvent>> {
const params: Record<string, string | number> = { page, pageSize };
if (instanceId) params['instanceId'] = instanceId;

View File

@@ -190,15 +190,21 @@
<app-card [noPadding]="true">
<div class="list">
@for (item of items(); track item.id) {
<div class="list-row">
<div class="list-row__main">
<div
class="list-row"
[class.list-row--expanded]="expandedItemId() === item.id"
>
<div
class="list-row__main list-row__main--clickable"
(click)="toggleItemExpand(item)"
>
<ng-icon name="tablerHistory" class="list-row__icon" />
<span class="list-row__title">
{{ itemDisplayName(item) }}
@if (item.seasonNumber > 0) {
<span class="list-row__extra">S{{ item.seasonNumber }}</span>
}
</span>
</span>
<app-badge [severity]="instanceTypeSeverity(item.instanceType)" size="sm">
{{ item.instanceType }}
</app-badge>
@@ -208,7 +214,53 @@
<span class="list-row__count-secondary">({{ item.searchCount }}x this cycle)</span>
}
<span class="list-row__time">{{ item.lastSearchedAt | date:'yyyy-MM-dd HH:mm' }}</span>
<ng-icon
[name]="expandedItemId() === item.id ? 'tablerChevronUp' : 'tablerChevronDown'"
class="list-row__chevron"
/>
</div>
@if (expandedItemId() === item.id) {
<div class="list-row__expand-details">
<div class="list-row__expand-header">Search History</div>
@if (detailLoading()) {
<span class="list-row__expand-loading">Loading...</span>
} @else if (detailEntries().length === 0) {
<span class="list-row__expand-loading">No search events found</span>
} @else {
<div class="expand-events">
@for (event of detailEntries(); track event.id) {
<div class="expand-events__row">
<ng-icon name="tablerSearch" class="list-row__icon" />
<app-badge [severity]="searchTypeSeverity(event.searchType)" size="sm">
{{ event.searchType }}
</app-badge>
@if (event.searchStatus) {
<app-badge [severity]="searchStatusSeverity(event.searchStatus)" size="sm">
{{ event.searchStatus }}
</app-badge>
}
@if (event.isDryRun) {
<app-badge severity="accent" size="sm">Dry Run</app-badge>
}
@if (event.cycleRunId) {
<span class="list-row__cycle">{{ event.cycleRunId.substring(0, 8) }}</span>
}
<span class="list-row__time">{{ event.timestamp | date:'yyyy-MM-dd HH:mm' }}</span>
</div>
@if (event.grabbedItems && event.grabbedItems.length > 0) {
<div class="list-row__detail">
<ng-icon name="tablerDownload" class="list-row__detail-icon" />
<span class="list-row__detail-text">
Grabbed: {{ formatGrabbedItems(event.grabbedItems) }}
</span>
</div>
}
}
</div>
}
</div>
}
</div>
} @empty {
<app-empty-state

View File

@@ -280,6 +280,71 @@
}
}
// Expanded state for list rows
.list-row {
&--expanded {
background: var(--glass-bg);
}
&__main--clickable {
cursor: pointer;
}
&__chevron {
font-size: 14px;
color: var(--text-tertiary);
flex-shrink: 0;
margin-left: auto;
transition: color var(--duration-fast) var(--ease-default);
}
&__main--clickable:hover &__chevron {
color: var(--text-secondary);
}
&__expand-details {
padding: var(--space-2) var(--space-4) var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-2);
animation: fade-in var(--duration-fast) var(--ease-default);
}
&__expand-header {
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
&__expand-loading {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
}
// Expanded event rows
.expand-events {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
overflow: hidden;
&__row {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-sm);
border-bottom: 1px solid var(--divider);
&:last-child {
border-bottom: none;
}
}
}
// Tablet
@media (max-width: 1024px) {
.list-row__main {
@@ -315,4 +380,8 @@
order: 4;
flex-basis: 100%;
}
.expand-events__row {
flex-wrap: wrap;
}
}

View File

@@ -85,6 +85,11 @@ export class SearchStatsComponent implements OnInit {
readonly pageSize = signal(50);
// Item expand
readonly expandedItemId = signal<string | null>(null);
readonly detailEntries = signal<SearchEvent[]>([]);
readonly detailLoading = signal(false);
constructor() {
effect(() => {
this.hub.searchStatsVersion(); // subscribe to changes
@@ -144,6 +149,30 @@ export class SearchStatsComponent implements OnInit {
this.loadActiveTab();
}
toggleItemExpand(item: SearchHistoryEntry): void {
const id = item.id;
if (this.expandedItemId() === id) {
this.expandedItemId.set(null);
this.detailEntries.set([]);
return;
}
this.expandedItemId.set(id);
this.detailLoading.set(true);
this.detailEntries.set([]);
this.api.getItemDetail(item.arrInstanceId, item.externalItemId, item.seasonNumber).subscribe({
next: (res) => {
this.detailEntries.set(res.entries);
this.detailLoading.set(false);
},
error: () => {
this.detailLoading.set(false);
this.toast.error('Failed to load item detail');
},
});
}
searchTypeSeverity(type: SeekerSearchType): 'info' | 'warning' {
return type === SeekerSearchType.Replacement ? 'warning' : 'info';
}
@@ -232,6 +261,8 @@ export class SearchStatsComponent implements OnInit {
private loadItems(): void {
this.loading.set(true);
this.expandedItemId.set(null);
this.detailEntries.set([]);
const instanceId = this.selectedInstanceId() || undefined;
this.api.getHistory(this.itemsPage(), this.pageSize(), instanceId, this.itemsSortBy()).subscribe({
next: (result) => {