From 9d7526ee57c7f81dca1a0c3e87cfa783d4667a95 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 26 Sep 2026 01:23:44 +0200 Subject: [PATCH] fix(feed): a reset clears the card's resume position at once, not only its status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The optimistic layer overrode only the status, so after "Reset — clear watch progress/status" the card kept its progress bar and its Continue button until the next refetch. Found in the E28 S3e1 UAT. --- .../src/components/Feed.reset.dom.test.tsx | 107 ++++++++++++++++++ frontend/src/components/Feed.tsx | 12 ++ 2 files changed, 119 insertions(+) create mode 100644 frontend/src/components/Feed.reset.dom.test.tsx diff --git a/frontend/src/components/Feed.reset.dom.test.tsx b/frontend/src/components/Feed.reset.dom.test.tsx new file mode 100644 index 00000000..949b6223 --- /dev/null +++ b/frontend/src/components/Feed.reset.dom.test.tsx @@ -0,0 +1,107 @@ +// @vitest-environment jsdom +// +// "Reset — clear watch progress/status" on a half-watched video. The optimistic layer overrode only +// the STATUS, so the card kept its resume position — its progress bar and its Continue button — until +// the next refetch (found in the E28 S3e1 UAT). The grid is replaced by a list that shows what Feed +// hands it: each video's position, and a button that fires the reset. +import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), +})); +vi.mock("./wizardContext", () => ({ useWizard: () => ({ openWizard: vi.fn() }) })); +vi.mock("./VirtualFeed", () => ({ + default: ({ + items, + onResetState, + }: { + items: { id: string; position_seconds: number }[]; + onResetState: (id: string) => void; + }) => ( +
    + {items.map((v) => ( +
  1. + {v.id}@{v.position_seconds} + +
  2. + ))} +
+ ), +})); + +import Feed from "./Feed"; +import { api, DEFAULT_TAG_MODES, type FeedFilters, type Video } from "../lib/api"; +import { qk } from "../lib/queryKeys"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; + globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const FILTERS: FeedFilters = { + tags: [], + tagModes: DEFAULT_TAG_MODES, + channelIds: [], + q: "", + sort: "newest", + scope: "my", + includeNormal: true, + includeShorts: false, + includeLive: false, + show: "unwatched", +}; + +const halfWatched = { + id: "v1", + title: "v1", + channel_id: "UCx", + channel_title: "Channel", + status: "new", + position_seconds: 95, + duration_seconds: 600, + published_at: "2026-01-01T00:00:00Z", + watch_url: "https://www.youtube.com/watch?v=v1", +} as unknown as Video; + +describe("Feed — resetting a half-watched video", () => { + it("clears its resume position at once, not only its status", async () => { + vi.spyOn(api, "feed").mockResolvedValue({ items: [halfWatched], next_cursor: null } as never); + vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 1, facets: {} } as never); + // Never settles: what the card shows before the server answers is the optimistic layer alone. + vi.spyOn(api, "clearState").mockReturnValue(new Promise(() => {}) as never); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} }); + render( + + {}} + view="cards" + setView={() => {}} + canRead + ytSearch="" + onYtSearch={() => {}} + onExitYtSearch={() => {}} + /> + , + ); + await waitFor(() => expect(screen.getByText("v1@95")).toBeTruthy()); + fireEvent.click(screen.getByText("reset v1")); + await waitFor(() => expect(screen.getByText("v1@0")).toBeTruthy()); + }); +}); diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 815f4e20..0ab343f6 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -145,6 +145,9 @@ export default function Feed({ : SORT_KEYS; const [overrides, setOverrides] = useState>({}); const [savedOverrides, setSavedOverrides] = useState>({}); + // A reset drops the resume position too, which a status override cannot say: without this the card + // kept its progress bar and its Continue button until the next refetch. + const [resetIds, setResetIds] = useState>({}); // The open player: which video and where to start (null = resume from saved position). const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>( null, @@ -238,6 +241,7 @@ export default function Feed({ setOverridesFor(filters); setOverrides({}); setSavedOverrides({}); + setResetIds({}); } // ...but NOT on infinite-scroll appends: fetchNextPage also bumps dataUpdatedAt while page 1's // rows are unchanged, so clearing overrides there would make a just-hidden card flash back. @@ -250,6 +254,7 @@ export default function Feed({ if (!appended) { setOverrides({}); setSavedOverrides({}); + setResetIds({}); } }, [query.dataUpdatedAt, pagesLen]); @@ -350,6 +355,7 @@ export default function Feed({ // Reset to pristine (drop the whole VideoState row, incl. resume position) — the // un-watch toggle can't clear an in-progress position, this can. setOverrides((o) => ({ ...o, [id]: "new" })); + setResetIds((r) => ({ ...r, [id]: true })); api .clearState(id) // Stale-mark only (see setState above): the optimistic override already reset the card. @@ -361,6 +367,11 @@ export default function Feed({ delete next[id]; return next; }); + setResetIds((r) => { + const next = { ...r }; + delete next[id]; + return next; + }); }); }, [qc], @@ -395,6 +406,7 @@ export default function Feed({ const status = overrides[v.id]; // read once so the narrowing sticks return status ? { ...v, status } : v; }) + .map((v) => (resetIds[v.id] ? { ...v, position_seconds: 0 } : v)) .map((v) => { const saved = savedOverrides[v.id]; return saved !== undefined ? { ...v, saved } : v;