fix(settings): derive the recommendations seed heading from the visible cards

The rail kept only the seeds that contributed a card to the original
selection, so a seed whose recommendations filled in after a relock (or
an offline prune) was missing from the "Because you watched" heading.
Every seed of the load is kept in order and the heading lists those that
contribute a card now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-29 09:05:05 +02:00
1 parent fb8830584e
commit 45a42ed57d
2 files changed
+43 -19

No files matched your search

@@ -1044,4 +1044,36 @@ describe('DashboardRecommendationsService', () => {
expect(service.seedTitles()).toEqual(['The Matrix']);
});
it('derives the seed heading from the cards visible now', async () => {
recentVod = [
{ title: 'The Matrix', type: 'movie' },
{ title: 'Blade Runner', type: 'movie' },
];
recentAll = [...recentVod];
const second = ['Akira', 'Ghost in the Shell', 'Paprika'];
enrichMovie.mockImplementation(async (query: { title: string }) => ({
recommendations: {
results:
query.title === 'The Matrix'
? recTitles.map((t, i) => rec(100 + i, t))
: second.map((t, i) => rec(300 + i, t)),
},
}));
// The second seed's recommendations sit in category 8.
matchTitles.mockImplementation(async (titles: string[]) =>
titles.map((title) =>
match(title, second.includes(title) ? { categoryId: 8 } : {})
)
);
// Locked at load: the second seed has no visible card yet.
withheldCategories.set(new Set([8]));
const service = createService();
await service.load();
expect(service.seedTitles()).toEqual(['The Matrix']);
// Unlocked: its cards appear, and so does its heading.
withheldCategories.set(new Set());
expect(service.seedTitles()).toEqual(['The Matrix', 'Blade Runner']);
});
});
@@ -70,7 +70,12 @@ export class DashboardRecommendationsService {
/** The last load's candidates and ALL their catalog matches. */
private readonly loaded = signal<LoadedRecommendations>(EMPTY_LOAD);
private readonly matchedSeedTitles = signal<readonly string[]>([]);
/**
* Every seed of the last load, most recent first. `seedTitles` keeps
* those that contribute a card NOW: a relock can hide some cards and let
* another seed's recommendations fill their places.
*/
private readonly seedOrder = signal<readonly string[]>([]);
/**
* 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
@@ -89,9 +94,7 @@ export class DashboardRecommendationsService {
/** Seeds that contributed at least one visible card, most recent first */
readonly seedTitles = computed(() => {
const contributed = new Set(this.items().map((item) => item.seedTitle));
return this.matchedSeedTitles().filter((title) =>
contributed.has(title)
);
return this.seedOrder().filter((title) => contributed.has(title));
});
readonly loading = signal(false);
@@ -117,7 +120,7 @@ export class DashboardRecommendationsService {
// The service outlives the dashboard (root-provided), so a
// cleared watch history must clear the rail too.
this.loaded.set(EMPTY_LOAD);
this.matchedSeedTitles.set([]);
this.seedOrder.set([]);
this.loadedKey = null;
return;
}
@@ -161,15 +164,8 @@ export class DashboardRecommendationsService {
MAX_ITEMS
);
if (matched.length >= MIN_RECOMMENDATION_MATCHES) {
const contributed = new Set(
matched.map((item) => item.seedTitle)
);
this.loaded.set(load);
this.matchedSeedTitles.set(
perSeed
.map((seed) => seed.seedTitle)
.filter((title) => contributed.has(title))
);
this.seedOrder.set(perSeed.map((seed) => seed.seedTitle));
// Latch only once EVERY seed answered. A seed that did
// not resolve may have failed transiently, and latching
// on its behalf would drop its recommendations for the
@@ -193,7 +189,7 @@ export class DashboardRecommendationsService {
// exact inputs (say, un-favoriting again) would
// otherwise hit the equality guard and stay empty.
this.loaded.set(EMPTY_LOAD);
this.matchedSeedTitles.set([]);
this.seedOrder.set([]);
this.loadedKey = null;
}
}
@@ -253,15 +249,11 @@ export class DashboardRecommendationsService {
);
if (cards.length < MIN_RECOMMENDATION_MATCHES) {
this.loaded.set(EMPTY_LOAD);
this.matchedSeedTitles.set([]);
this.seedOrder.set([]);
return;
}
const contributed = new Set(cards.map((item) => item.seedTitle));
this.loaded.set(kept);
this.matchedSeedTitles.set(
this.matchedSeedTitles().filter((title) => contributed.has(title))
);
}
/**