mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(settings): fall back to unlocked copies of a withheld title match
The dashboard trending and recommendation rails and the "similar in your portals" rails picked one match per title at load, so hiding a withheld match lost a copy of the same title in an unlocked portal. They now keep every row the lookup returned and pick the match on read from the rows the parental lock does not withhold. Adds the release note. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
19e25f1e4c
commit
fb8830584e
10 files changed
+255
-88
No files matched your search
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: fix
|
||||
area: settings
|
||||
issues: [1723]
|
||||
---
|
||||
|
||||
Locking the app now also hides locked titles from "available in your library" hints on actor, discover and dashboard pages and from the "similar in your portals" rails. A title that also exists in an unlocked playlist stays available through that copy.
|
||||
@@ -157,7 +157,12 @@ routes, the dashboard trending and recommendation rails, the four
|
||||
`CrossPortalSimilarService.visible`. Every match carries its provider
|
||||
category id, and the predicate reads the lock state, so a relock hides
|
||||
matches cached while unlocked (and those of a lookup issued before it) at
|
||||
once, with no re-query; an unlock shows them again.
|
||||
once, with no re-query; an unlock shows them again. Consumers keep ALL the
|
||||
rows a lookup returned and pick a title's match from the visible ones on
|
||||
read (the Actor/Discover indexes, the trending rail, the recommendation
|
||||
rail through `buildRecommendationItems`, and each "similar" item's
|
||||
`candidates`), so a title that also exists in an unlocked portal stays
|
||||
available through that copy instead of disappearing with the locked one.
|
||||
The window before the initial read settles is treated the same way
|
||||
(`ParentalLockLockStore.readable` is false until then): settings can report
|
||||
the feature as on before the locks are known — and the workspace route's
|
||||
|
||||
@@ -42,6 +42,8 @@ describe('CrossPortalSimilarService', () => {
|
||||
matchTitles,
|
||||
isWithheld: (m: CatalogTitleMatch) =>
|
||||
withheld.has(m.xtreamId),
|
||||
visibleMatches: (ms: CatalogTitleMatch[]) =>
|
||||
ms.filter((m) => !withheld.has(m.xtreamId)),
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -151,4 +153,28 @@ describe('CrossPortalSimilarService', () => {
|
||||
|
||||
expect(service.visible([kept, locked])).toEqual([kept]);
|
||||
});
|
||||
|
||||
it('falls back to an unlocked candidate when the chosen match is withheld', async () => {
|
||||
matchTitles.mockResolvedValue([
|
||||
match(),
|
||||
match({
|
||||
playlistId: 'pl-2',
|
||||
playlistName: 'Portal Two',
|
||||
xtreamId: 43,
|
||||
}),
|
||||
]);
|
||||
const service = createService();
|
||||
const [item] = await service.matchRecommendations(
|
||||
[rec('The Matrix')],
|
||||
'movie'
|
||||
);
|
||||
expect(item.match.xtreamId).toBe(42);
|
||||
|
||||
withheld.add(42);
|
||||
const [visible] = service.visible([item]);
|
||||
expect(visible.match.playlistName).toBe('Portal Two');
|
||||
|
||||
withheld.add(43);
|
||||
expect(service.visible([item])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {
|
||||
CatalogTitleMatch,
|
||||
normalizeTitleKeys,
|
||||
TmdbRecommendation,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
CatalogTitleLookup,
|
||||
CatalogTitleMatchService,
|
||||
groupTitleMatchesByKey,
|
||||
pickTitleMatch,
|
||||
@@ -16,6 +18,12 @@ export interface CrossPortalSimilarItem {
|
||||
year: number | null;
|
||||
/** Where to navigate: playlist + category + item in that portal */
|
||||
match: CatalogTitleMatch;
|
||||
/**
|
||||
* Every eligible catalog row found for this title (the chosen `match`
|
||||
* included), so `visible()` can fall back to a copy in an unlocked
|
||||
* portal when the parental lock withholds the chosen one.
|
||||
*/
|
||||
candidates?: readonly CatalogTitleMatch[];
|
||||
}
|
||||
|
||||
const DEFAULT_LIMIT = 12;
|
||||
@@ -64,14 +72,12 @@ export class CrossPortalSimilarService {
|
||||
if (items.length >= limit) {
|
||||
break;
|
||||
}
|
||||
const match = pickTitleMatch(
|
||||
{
|
||||
type,
|
||||
titles: [recommendation.title],
|
||||
year: recommendation.year,
|
||||
},
|
||||
grouped
|
||||
);
|
||||
const lookup = {
|
||||
type,
|
||||
titles: [recommendation.title],
|
||||
year: recommendation.year,
|
||||
};
|
||||
const match = pickTitleMatch(lookup, grouped);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
@@ -85,18 +91,45 @@ export class CrossPortalSimilarService {
|
||||
posterUrl: recommendation.posterUrl,
|
||||
year: recommendation.year,
|
||||
match,
|
||||
candidates: titleMatchCandidates(lookup, grouped),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* `items` without the ones whose match the parental lock withholds now.
|
||||
* Reactive (see `CatalogTitleMatchService.isWithheld`): filter inside
|
||||
* a `computed` so cached rails follow relock and unlock.
|
||||
* `items` as the parental lock allows them now. An item whose chosen
|
||||
* match is withheld falls back to its best unlocked candidate, and is
|
||||
* dropped only when none is left (or another item already shows that
|
||||
* row). Reactive (see `CatalogTitleMatchService.isWithheld`): call it
|
||||
* inside a `computed` so cached rails follow relock and unlock.
|
||||
*/
|
||||
visible<T extends CrossPortalSimilarItem>(items: readonly T[]): T[] {
|
||||
return items.filter((item) => !this.titleMatch.isWithheld(item.match));
|
||||
const seen = new Set<string>();
|
||||
const visible: T[] = [];
|
||||
for (const item of items) {
|
||||
const match = this.titleMatch.isWithheld(item.match)
|
||||
? pickTitleMatch(
|
||||
{
|
||||
type: item.match.type,
|
||||
titles: [item.title],
|
||||
year: item.year,
|
||||
},
|
||||
groupTitleMatchesByKey(
|
||||
this.titleMatch.visibleMatches(item.candidates ?? [])
|
||||
)
|
||||
)
|
||||
: item.match;
|
||||
const key = match
|
||||
? `${match.playlistId}:${match.type}:${match.xtreamId}`
|
||||
: null;
|
||||
if (!match || !key || seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
visible.push(match === item.match ? item : { ...item, match });
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
/** Route array for one match: the item's detail view in its portal */
|
||||
@@ -110,3 +143,16 @@ export class CrossPortalSimilarService {
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/** The grouped rows a lookup's titles resolve to, in grouping order. */
|
||||
function titleMatchCandidates(
|
||||
lookup: CatalogTitleLookup,
|
||||
grouped: ReadonlyMap<string, CatalogTitleMatch[]>
|
||||
): CatalogTitleMatch[] {
|
||||
const candidates: CatalogTitleMatch[] = [];
|
||||
for (const title of lookup.titles) {
|
||||
const key = `${lookup.type}:${normalizeTitleKeys(title).exact}`;
|
||||
candidates.push(...(grouped.get(key) ?? []));
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
+24
@@ -127,6 +127,8 @@ describe('DashboardRecommendationsService', () => {
|
||||
isAvailable: options.matchingAvailable ?? true,
|
||||
matchTitles,
|
||||
isWithheld,
|
||||
visibleMatches: (matches: CatalogTitleMatch[]) =>
|
||||
matches.filter((m) => !isWithheld(m)),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -195,6 +197,28 @@ describe('DashboardRecommendationsService', () => {
|
||||
expect(service.seedTitles()).toEqual(['The Matrix']);
|
||||
});
|
||||
|
||||
it('keeps cards available through an unlocked copy when the chosen one is withheld', async () => {
|
||||
matchTitles.mockImplementation(async (titles: string[]) =>
|
||||
titles.flatMap((title) => [
|
||||
match(title),
|
||||
match(title, {
|
||||
playlistId: 'pl-2',
|
||||
playlistName: 'Other',
|
||||
categoryId: 8,
|
||||
}),
|
||||
])
|
||||
);
|
||||
const service = createService();
|
||||
await service.load();
|
||||
expect(service.items()[0].match.playlistName).toBe('My Portal');
|
||||
|
||||
withheldCategories.set(new Set([7]));
|
||||
expect(service.items()).toHaveLength(recTitles.length);
|
||||
expect(
|
||||
service.items().every((item) => item.match.playlistName === 'Other')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does nothing when TMDB is disabled', async () => {
|
||||
isEnabled.mockReturnValue(false);
|
||||
const service = createService();
|
||||
|
||||
@@ -2,10 +2,11 @@ import { computed, Injectable, inject, signal } from '@angular/core';
|
||||
import {
|
||||
CatalogTitleMatchService,
|
||||
TmdbEnrichmentService,
|
||||
groupTitleMatchesByKey,
|
||||
pickTitleMatch,
|
||||
} from '@iptvnator/services';
|
||||
import { normalizeTitleKeys } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
CatalogTitleMatch,
|
||||
normalizeTitleKeys,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { DashboardDataService } from './dashboard-data.service';
|
||||
import {
|
||||
DashboardTmdbLookupItem,
|
||||
@@ -13,16 +14,22 @@ import {
|
||||
dashboardTmdbLookupKey,
|
||||
} from './dashboard-tmdb-lookup.util';
|
||||
import {
|
||||
DashboardRecommendationItem,
|
||||
ExclusionIndex,
|
||||
RecommendationCandidate,
|
||||
buildLoadKey,
|
||||
candidateLookup,
|
||||
buildRecommendationItems,
|
||||
isExcludedCandidate,
|
||||
toCandidates,
|
||||
trustedReleaseYear,
|
||||
} from './dashboard-recommendations.util';
|
||||
|
||||
interface LoadedRecommendations {
|
||||
candidates: readonly RecommendationCandidate[];
|
||||
matches: readonly CatalogTitleMatch[];
|
||||
}
|
||||
|
||||
const EMPTY_LOAD: LoadedRecommendations = { candidates: [], matches: [] };
|
||||
|
||||
interface SeedRecommendations {
|
||||
resolved: boolean;
|
||||
seedTitle: string;
|
||||
@@ -61,16 +68,21 @@ export class DashboardRecommendationsService {
|
||||
private readonly titleMatch = inject(CatalogTitleMatchService);
|
||||
private readonly data = inject(DashboardDataService);
|
||||
|
||||
private readonly matchedItems = signal<DashboardRecommendationItem[]>([]);
|
||||
/** The last load's candidates and ALL their catalog matches. */
|
||||
private readonly loaded = signal<LoadedRecommendations>(EMPTY_LOAD);
|
||||
private readonly matchedSeedTitles = signal<readonly string[]>([]);
|
||||
/**
|
||||
* The matched cards the parental lock does not withhold, filtered on
|
||||
* read so a relock hides cards matched while unlocked. Under the match
|
||||
* threshold the rail hides, as it does on load.
|
||||
* The cards, built on READ from the matches the parental lock does not
|
||||
* withhold: a relock hides a card matched while unlocked, and a title
|
||||
* that also exists in an unlocked portal stays on the rail through that
|
||||
* copy. Under the match threshold the rail hides, as it does on load.
|
||||
*/
|
||||
readonly items = computed(() => {
|
||||
const visible = this.matchedItems().filter(
|
||||
(item) => !this.titleMatch.isWithheld(item.match)
|
||||
const { candidates, matches } = this.loaded();
|
||||
const visible = buildRecommendationItems(
|
||||
candidates,
|
||||
this.titleMatch.visibleMatches(matches),
|
||||
MAX_ITEMS
|
||||
);
|
||||
return visible.length < MIN_RECOMMENDATION_MATCHES ? [] : visible;
|
||||
});
|
||||
@@ -104,7 +116,7 @@ export class DashboardRecommendationsService {
|
||||
if (seeds.length === 0) {
|
||||
// The service outlives the dashboard (root-provided), so a
|
||||
// cleared watch history must clear the rail too.
|
||||
this.matchedItems.set([]);
|
||||
this.loaded.set(EMPTY_LOAD);
|
||||
this.matchedSeedTitles.set([]);
|
||||
this.loadedKey = null;
|
||||
return;
|
||||
@@ -142,12 +154,17 @@ export class DashboardRecommendationsService {
|
||||
perSeed.map((seed) => seed.entries),
|
||||
excluded
|
||||
);
|
||||
const matched = await this.attachMatches(candidates);
|
||||
const load = await this.attachMatches(candidates);
|
||||
const matched = buildRecommendationItems(
|
||||
load.candidates,
|
||||
load.matches,
|
||||
MAX_ITEMS
|
||||
);
|
||||
if (matched.length >= MIN_RECOMMENDATION_MATCHES) {
|
||||
const contributed = new Set(
|
||||
matched.map((item) => item.seedTitle)
|
||||
);
|
||||
this.matchedItems.set(matched);
|
||||
this.loaded.set(load);
|
||||
this.matchedSeedTitles.set(
|
||||
perSeed
|
||||
.map((seed) => seed.seedTitle)
|
||||
@@ -175,7 +192,7 @@ export class DashboardRecommendationsService {
|
||||
// rail that was just cleared, and returning to those
|
||||
// exact inputs (say, un-favoriting again) would
|
||||
// otherwise hit the equality guard and stay empty.
|
||||
this.matchedItems.set([]);
|
||||
this.loaded.set(EMPTY_LOAD);
|
||||
this.matchedSeedTitles.set([]);
|
||||
this.loadedKey = null;
|
||||
}
|
||||
@@ -200,7 +217,7 @@ export class DashboardRecommendationsService {
|
||||
* under the match threshold hides the rail, as everywhere else.
|
||||
*/
|
||||
private dropExcludedCards(excluded: ExclusionIndex): void {
|
||||
const current = this.matchedItems();
|
||||
const { candidates, matches } = this.loaded();
|
||||
// A card whose playlist is gone would navigate to a dead route,
|
||||
// and the failed refresh is no excuse for keeping it — this is
|
||||
// the only path that can reach a deleted playlist without the
|
||||
@@ -208,12 +225,18 @@ export class DashboardRecommendationsService {
|
||||
const livePlaylists = new Set(
|
||||
this.data.playlists().map((playlist) => playlist._id)
|
||||
);
|
||||
const kept = current.filter(
|
||||
(item) =>
|
||||
livePlaylists.has(item.match.playlistId) &&
|
||||
!isExcludedCandidate(item, excluded)
|
||||
);
|
||||
if (kept.length === current.length) {
|
||||
const kept: LoadedRecommendations = {
|
||||
candidates: candidates.filter(
|
||||
(candidate) => !isExcludedCandidate(candidate, excluded)
|
||||
),
|
||||
matches: matches.filter((match) =>
|
||||
livePlaylists.has(match.playlistId)
|
||||
),
|
||||
};
|
||||
if (
|
||||
kept.candidates.length === candidates.length &&
|
||||
kept.matches.length === matches.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -223,14 +246,19 @@ export class DashboardRecommendationsService {
|
||||
// would hit the equality guard and leave the rail as it is now.
|
||||
this.loadedKey = null;
|
||||
|
||||
if (kept.length < MIN_RECOMMENDATION_MATCHES) {
|
||||
this.matchedItems.set([]);
|
||||
const cards = buildRecommendationItems(
|
||||
kept.candidates,
|
||||
kept.matches,
|
||||
MAX_ITEMS
|
||||
);
|
||||
if (cards.length < MIN_RECOMMENDATION_MATCHES) {
|
||||
this.loaded.set(EMPTY_LOAD);
|
||||
this.matchedSeedTitles.set([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const contributed = new Set(kept.map((item) => item.seedTitle));
|
||||
this.matchedItems.set(kept);
|
||||
const contributed = new Set(cards.map((item) => item.seedTitle));
|
||||
this.loaded.set(kept);
|
||||
this.matchedSeedTitles.set(
|
||||
this.matchedSeedTitles().filter((title) => contributed.has(title))
|
||||
);
|
||||
@@ -425,12 +453,12 @@ export class DashboardRecommendationsService {
|
||||
|
||||
private async attachMatches(
|
||||
candidates: readonly RecommendationCandidate[]
|
||||
): Promise<DashboardRecommendationItem[]> {
|
||||
): Promise<LoadedRecommendations> {
|
||||
if (candidates.length === 0) {
|
||||
return [];
|
||||
return EMPTY_LOAD;
|
||||
}
|
||||
// Both aliases go into the ONE batched request; the index lookup
|
||||
// below prefers the localized form. Built with a loop rather than
|
||||
// prefers the localized form. Built with a loop rather than
|
||||
// flatMap — the web app compiles this lib against `lib: es2018`,
|
||||
// which predates Array.prototype.flatMap.
|
||||
const queryTitles: string[] = [];
|
||||
@@ -441,29 +469,6 @@ export class DashboardRecommendationsService {
|
||||
}
|
||||
}
|
||||
const matches = await this.titleMatch.matchTitles(queryTitles);
|
||||
const grouped = groupTitleMatchesByKey(matches);
|
||||
|
||||
// Title collisions are resolved HERE rather than before matching:
|
||||
// two candidates that resolve to the same catalog row would render
|
||||
// as duplicate cards opening the same item, while same-titled
|
||||
// remakes resolve to different rows and both belong on the rail.
|
||||
const items: DashboardRecommendationItem[] = [];
|
||||
const claimedRows = new Set<string>();
|
||||
for (const candidate of candidates) {
|
||||
const match = pickTitleMatch(candidateLookup(candidate), grouped);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const rowKey = `${match.playlistId}:${match.type}:${match.xtreamId}`;
|
||||
if (claimedRows.has(rowKey)) {
|
||||
continue;
|
||||
}
|
||||
claimedRows.add(rowKey);
|
||||
items.push({ ...candidate, match });
|
||||
if (items.length === MAX_ITEMS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return items;
|
||||
return { candidates, matches };
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import { extractYear, tmdbPosterUrl } from '@iptvnator/services';
|
||||
import {
|
||||
extractYear,
|
||||
groupTitleMatchesByKey,
|
||||
pickTitleMatch,
|
||||
tmdbPosterUrl,
|
||||
} from '@iptvnator/services';
|
||||
import type { CatalogTitleLookup, TmdbSearchResult } from '@iptvnator/services';
|
||||
import {
|
||||
CatalogTitleMatch,
|
||||
@@ -223,3 +228,38 @@ export function toCandidates(
|
||||
})
|
||||
.filter((entry) => entry.tmdbId > 0 && entry.title !== '');
|
||||
}
|
||||
|
||||
/**
|
||||
* The rail's cards: each candidate with the catalog row it resolves to.
|
||||
* Title collisions are resolved HERE rather than before matching: two
|
||||
* candidates that resolve to the same catalog row would render as
|
||||
* duplicate cards opening the same item, while same-titled remakes
|
||||
* resolve to different rows and both belong on the rail. Pure, so the
|
||||
* service can rebuild the rail from the matches the parental lock does
|
||||
* not withhold whenever the lock changes.
|
||||
*/
|
||||
export function buildRecommendationItems(
|
||||
candidates: readonly RecommendationCandidate[],
|
||||
matches: readonly CatalogTitleMatch[],
|
||||
limit: number
|
||||
): DashboardRecommendationItem[] {
|
||||
const grouped = groupTitleMatchesByKey(matches);
|
||||
const items: DashboardRecommendationItem[] = [];
|
||||
const claimedRows = new Set<string>();
|
||||
for (const candidate of candidates) {
|
||||
const match = pickTitleMatch(candidateLookup(candidate), grouped);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const rowKey = `${match.playlistId}:${match.type}:${match.xtreamId}`;
|
||||
if (claimedRows.has(rowKey)) {
|
||||
continue;
|
||||
}
|
||||
claimedRows.add(rowKey);
|
||||
items.push({ ...candidate, match });
|
||||
if (items.length === limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
@@ -23,8 +23,7 @@ export interface SourceExpiryFacts {
|
||||
}
|
||||
|
||||
export type SourceExpiryBadge =
|
||||
| { kind: 'expired' }
|
||||
| { kind: 'expiring'; daysLeft: number };
|
||||
{ kind: 'expired' } | { kind: 'expiring'; daysLeft: number };
|
||||
|
||||
/**
|
||||
* Decides whether a source card should carry an expiry badge. Returns null
|
||||
|
||||
@@ -60,6 +60,8 @@ describe('DashboardTrendingService', () => {
|
||||
isAvailable: options.matchingAvailable ?? true,
|
||||
matchTitles,
|
||||
isWithheld,
|
||||
visibleMatches: (matches: CatalogTitleMatch[]) =>
|
||||
matches.filter((m) => !isWithheld(m)),
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -116,6 +118,19 @@ describe('DashboardTrendingService', () => {
|
||||
expect(service.items()[0].match?.playlistName).toBe('My Portal');
|
||||
});
|
||||
|
||||
it('falls back to a copy in an unlocked portal when the chosen match is withheld', async () => {
|
||||
matchTitles.mockResolvedValue([
|
||||
match(),
|
||||
match({ playlistId: 'pl-2', playlistName: 'Other', categoryId: 8 }),
|
||||
]);
|
||||
const service = createService();
|
||||
await service.load();
|
||||
expect(service.items()[0].match?.playlistId).toBe('pl-1');
|
||||
|
||||
withheldCategories.set(new Set([7]));
|
||||
expect(service.items()[0].match?.playlistName).toBe('Other');
|
||||
});
|
||||
|
||||
it('rejects year-incompatible base-tier matches', async () => {
|
||||
getTrendingWeek.mockResolvedValue([
|
||||
entry({ title: 'Blade Runner', year: 1982 }),
|
||||
|
||||
@@ -26,19 +26,26 @@ export class DashboardTrendingService {
|
||||
private readonly enrichment = inject(TmdbEnrichmentService);
|
||||
private readonly titleMatch = inject(CatalogTitleMatchService);
|
||||
|
||||
private readonly matchedItems = signal<DashboardTrendingItem[]>([]);
|
||||
private readonly loaded = signal<{
|
||||
entries: TmdbTrendingEntry[];
|
||||
matches: CatalogTitleMatch[];
|
||||
}>({ entries: [], matches: [] });
|
||||
/**
|
||||
* Trending entries with their library match, dropped (the entry stays,
|
||||
* unavailable) while the parental lock withholds its category. Filtered
|
||||
* on read, so a relock hides matches found while unlocked.
|
||||
* Trending entries with their library match, picked on READ from the
|
||||
* matches the parental lock does not withhold: a relock hides a match
|
||||
* found while unlocked, and a title that also exists in an unlocked
|
||||
* portal stays available through that copy.
|
||||
*/
|
||||
readonly items = computed(() =>
|
||||
this.matchedItems().map((item) =>
|
||||
item.match && this.titleMatch.isWithheld(item.match)
|
||||
? { ...item, match: null }
|
||||
: item
|
||||
)
|
||||
);
|
||||
readonly items = computed<DashboardTrendingItem[]>(() => {
|
||||
const { entries, matches } = this.loaded();
|
||||
const grouped = groupTitleMatchesByKey(
|
||||
this.titleMatch.visibleMatches(matches)
|
||||
);
|
||||
return entries.map((entry) => ({
|
||||
...entry,
|
||||
match: this.matchFor(entry, grouped),
|
||||
}));
|
||||
});
|
||||
readonly loading = signal(false);
|
||||
|
||||
private loadedOnce = false;
|
||||
@@ -67,14 +74,7 @@ export class DashboardTrendingService {
|
||||
const matches = await this.titleMatch.matchTitles(
|
||||
entries.map((entry) => entry.title)
|
||||
);
|
||||
const grouped = groupTitleMatchesByKey(matches);
|
||||
|
||||
this.matchedItems.set(
|
||||
entries.map((entry) => ({
|
||||
...entry,
|
||||
match: this.matchFor(entry, grouped),
|
||||
}))
|
||||
);
|
||||
this.loaded.set({ entries, matches });
|
||||
this.loadedOnce = true;
|
||||
} catch (error) {
|
||||
console.warn('Dashboard trending load failed:', error);
|
||||
|
||||
Reference in new issue
Block a user