Files
jellytau/src/lib/stores/favorites.ts
T
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check`
reported 199 unformatted files and ran in no workflow and in no git hook,
so .prettierrc (printWidth 100, trailing commas) described an intention
rather than the tree.

This is the one-time sweep that makes the check gateable. Whitespace and
token-reflow only -- no behavioural change: `bun run check` reports 0
errors and all 1053 frontend tests pass before and after.

Kept out of every other commit on purpose. A 199-file diff mixed with
real changes is unreviewable, and the next commit turns format:check
into a hard CI gate so this cannot silently accumulate again.
2026-08-21 17:41:44 +02:00

90 lines
3.0 KiB
TypeScript

// Favourites overlay — in-session heart state shared across every surface.
//
// The durable record lives in Rust (local `user_data` + the Jellyfin server).
// This store holds only what the *current session* has changed, so a heart
// tapped on a card is reflected on the detail page and the item vanishes from
// the Favourites grid without anyone refetching. It is view state, not truth.
//
// TRACES: UR-068 | DR-119 | UT-105
import { derived, get, writable } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
/** Item id → favourite state set during this session. */
const overrides = writable<Map<string, boolean>>(new Map());
export const favoriteOverrides = { subscribe: overrides.subscribe };
/**
* Record a favourite state locally so every mounted view agrees immediately.
* Called by the toggle service after the optimistic local write.
*/
export function setFavorite(itemId: string, isFavorite: boolean): void {
overrides.update((map) => {
const next = new Map(map);
next.set(itemId, isFavorite);
return next;
});
}
/**
* Forget a session override, so the item's own `userData` is authoritative
* again. Used when the backend reports the server's state changed underneath
* us (`favorites-changed`) — the fresh fetch that follows carries the truth.
*/
export function clearFavorite(itemId: string): void {
overrides.update((map) => {
if (!map.has(itemId)) return map;
const next = new Map(map);
next.delete(itemId);
return next;
});
}
export function clearAllFavorites(): void {
overrides.set(new Map());
}
/**
* Resolution order: a session override wins, then the item's own server-sent
* `userData`, then "not favourited".
*
* The override has to win, or tapping the heart on a card would flip back the
* moment the (unchanged) item object re-rendered.
*
* TRACES: UR-068 | DR-119 | UT-105
*/
export function resolveIsFavorite(
item: Pick<MediaItem, "id" | "userData"> | null | undefined,
overrideMap: Map<string, boolean>,
): boolean {
if (!item) return false;
const override = overrideMap.get(item.id);
if (override !== undefined) return override;
return item.userData?.isFavorite ?? false;
}
/** Non-reactive read, for call sites outside a component. */
export function isFavoriteNow(item: Pick<MediaItem, "id" | "userData">): boolean {
return resolveIsFavorite(item, get(overrides));
}
/**
* Drop the items a listing should no longer show once un-favourited.
*
* Pure so it can be unit-tested without mounting the page: un-hearting on the
* Favourites grid must remove the card, while a *newly* favourited item is
* left alone (it belongs to whatever scope the caller fetched).
*
* TRACES: UR-067 | DR-117, DR-119 | UT-106
*/
export function retainFavorites<T extends Pick<MediaItem, "id" | "userData">>(
items: T[],
overrideMap: Map<string, boolean>,
): T[] {
return items.filter((item) => resolveIsFavorite(item, overrideMap));
}
/** Count of items still favourited, for "hide the row when empty" decisions. */
export const hasOverrides = derived(overrides, ($o) => $o.size > 0);