Files
siftlode/frontend/src/lib/columnSort.ts
T
peter f80ed115ad fix(plex,channels): deep-review fixes — durable watch-sync + no shared-link clobber
Deep-review of the quickfix batch surfaced two data/preference-loss bugs plus
smaller items; all fixed with tests.

- watch_sync _pull_apply: don't let the Plex-authoritative pull overwrite a DIRTY
  local row (a change whose immediate push failed) — that stranded the value
  forever since _repush_dirty only retries dirty rows. Leave it dirty for re-push.
- PlexProvider / ChannelsProvider: a partial shared link opened in a fresh tab
  persisted mount-time DEFAULTS over the account's stored scope/sort/filters
  (base read before the account was pinned). Persist/keep only URL-present dims;
  re-hydrate the omitted ones from storage.
- playback resume write-back: guard against out-of-order delivery (a superseded
  older push now drops) and evict the throttle map so it can't grow unbounded.
- PlexPlayer: announce mid-playback buffering to assistive tech (role=status +
  sr-only label); new plex.player.buffering (HU+EN).
- columnSort: hoist the shared key:dir sort codec used by both URL-state modules.
- Tests: dirty-guard + flag-settle + per-key throttle scoping + superseded-push
  (backend); hold-repeat/suppress-click/unmount + partial-link no-clobber +
  refetch-doesn't-clobber-undo (frontend). Backend pytest 475, FE vitest 519.
2026-08-27 23:54:44 +02:00

116 lines
5.3 KiB
TypeScript

// Shared column-sort primitives, lifted out of DataTable so a NON-table renderer (the channel
// manager's card layout) can sort the same rows the same way — one comparator, one source of truth.
// Kept structural (no `Column` import) so it has zero coupling to DataTable.
export type SortState = { key: string; dir: "asc" | "desc" } | null;
interface Sortable<T> {
key: string;
sortValue?: (row: T) => string | number;
}
/** Sort a copy of `rows` by the sortable column named in `sort` (numeric diff or locale compare).
* Returns `rows` unchanged when there's no sort or the column has no `sortValue`. */
export function sortRows<T>(rows: T[], columns: Sortable<T>[], sort: SortState): T[] {
if (!sort) return rows;
const get = columns.find((c) => c.key === sort.key)?.sortValue;
if (!get) return rows;
return [...rows].sort((a, b) => {
const av = get(a);
const bv = get(b);
const cmp =
typeof av === "number" && typeof bv === "number"
? av - bv
: String(av).localeCompare(String(bv));
return sort.dir === "asc" ? cmp : -cmp;
});
}
/** Coerce a stored value to a SortState. Anything malformed (corrupt storage, a shape from an older
* build) becomes "no sort" rather than flowing a junk `dir` into the comparator. */
export function parseSortState(raw: unknown): SortState {
if (!raw || typeof raw !== "object") return null;
const { key, dir } = raw as { key?: unknown; dir?: unknown };
return typeof key === "string" && (dir === "asc" || dir === "desc") ? { key, dir } : null;
}
/** Encode a SortState to the compact `key:dir` wire form the shareable-URL modules (plex/channels)
* put in the address bar. Sort keys are simple words (no colon), so the plain join round-trips. Returns
* null for no-sort. A module with a default sort short-circuits that to null at its own call site. */
export function encodeSortParam(s: SortState): string | null {
return s ? `${s.key}:${s.dir}` : null;
}
/** Inverse of `encodeSortParam`: parse `key:dir` (or a bare key → asc) from the URL back into a
* SortState, healing anything malformed to null. One codec for both URL-state modules — no drift. */
export function decodeSortParam(raw: string): SortState {
const i = raw.indexOf(":");
const key = i === -1 ? raw : raw.slice(0, i);
const dir = i === -1 ? "asc" : raw.slice(i + 1);
return parseSortState({ key, dir });
}
/** The header-click cycle: unsorted → ascending → descending → unsorted. */
export function cycleSort(current: SortState, key: string): SortState {
if (!current || current.key !== key) return { key, dir: "asc" };
if (current.dir === "asc") return { key, dir: "desc" };
return null;
}
// --- the source-agnostic sort vocabulary -------------------------------------------------
// A `SortOption[]` is the one shape every content surface (YT feed, Plex library, channel
// manager, discovery) hands to the shared `SortControl`. A table derives it from its columns;
// the feed and Plex declare it explicitly. Direction lives in `SortState`, never encoded into
// the key — the feed's wire string (`feedSort.ts`) is a codec at the server boundary only.
export interface SortOption {
key: string;
label: string;
/** No meaningful asc/desc flip (the feed's `shuffle`/`relevance`) — hides the direction toggle. */
directionless?: boolean;
/** The natural first direction when this option is freshly picked (defaults to `"asc"`). */
defaultDir?: "asc" | "desc";
}
/** Decide the next `SortState` when the user PICKS `key` from a dropdown/chip (NOT the header-click
* cycle — that's `cycleSort`). Re-picking the same key keeps its direction; a new key starts at its
* `defaultDir` (asc fallback); a directionless option carries a stable dir; an empty key clears sort. */
export function pickSort(options: SortOption[], prev: SortState, key: string): SortState {
if (!key) return null;
const opt = options.find((o) => o.key === key);
if (!opt) return null;
if (opt.directionless) return { key, dir: "desc" };
if (prev?.key === key) return prev;
return { key, dir: opt.defaultDir ?? "asc" };
}
/** Collapse a sort that names an option no longer offered (a renamed column, or Plex's movie/show
* scope swap dropping a key) to `fallback` — otherwise a stale key claims a sort nothing applies. */
export function healSort(
sort: SortState,
options: SortOption[],
fallback: SortState = null,
): SortState {
return sort && options.some((o) => o.key === sort.key) ? sort : fallback;
}
/** Hydrate a persisted raw string into a `SortState`. Accepts the JSON `{key,dir}` shape and tolerates
* a legacy bare-key string (older builds stored just the key). When `options` is empty, the value is
* parsed but NOT healed — for a table/provider that owns no vocabulary itself and heals for display
* elsewhere (e.g. `ColumnSortControl`); a non-empty `options` heals an absent key to `fallback`. */
export function hydrateSort(
raw: string | null,
options: SortOption[],
fallback: SortState = null,
): SortState {
if (!raw) return fallback;
const heal = (s: SortState): SortState =>
options.length === 0 ? (s ?? fallback) : healSort(s, options, fallback);
try {
return heal(parseSortState(JSON.parse(raw)));
} catch {
const opt = options.find((o) => o.key === raw);
return opt ? { key: raw, dir: opt.defaultDir ?? "asc" } : fallback;
}
}