From 85fd9b6704c7ec84793024bec123d73396a12bc7 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 1 Nov 2026 01:30:30 +0100 Subject: [PATCH] feat(channels): an Added column, and About cards that wait for a resting pointer The channel manager lists when each follow reached the list (the subscription import that first saw it, or a subscribe made here) as a sortable Added date after Last upload. The About cell's card opens after the pointer rests on it for half a second instead of at once, and a scroll puts an open one away. --- backend/app/routes/channels.py | 4 ++ backend/tests/test_catalog_routes.py | 20 ++++++ frontend/src/i18n/locales/en/channels.json | 1 + frontend/src/i18n/locales/hu/channels.json | 1 + frontend/src/lib/api/channels.ts | 3 + .../modules/channels/AboutCell.dom.test.tsx | 69 +++++++++++++++++++ frontend/src/modules/channels/AboutCell.tsx | 10 ++- .../channels/channelColumns.dom.test.tsx | 39 +++++++++++ .../src/modules/channels/channelColumns.tsx | 16 ++++- .../channels/useChannelActions.dom.test.tsx | 1 + 10 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 frontend/src/modules/channels/AboutCell.dom.test.tsx diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index 284ea366..beedfa9e 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -76,6 +76,10 @@ def list_channels(user: User = Depends(current_user), db: Session = Depends(get_ "details_synced": ch.details_synced_at is not None, "recent_synced": ch.recent_synced_at is not None, "backfill_done": ch.backfill_done, + # When the follow reached this list: written by the subscription import the run that + # first saw it, or by a subscribe made here — not the channel row's own date, which an + # explored or discovered channel had before it was followed. + "added_at": iso(sub.created_at), } for sub, ch in rows ] diff --git a/backend/tests/test_catalog_routes.py b/backend/tests/test_catalog_routes.py index e144433a..01be059a 100644 --- a/backend/tests/test_catalog_routes.py +++ b/backend/tests/test_catalog_routes.py @@ -72,3 +72,23 @@ def test_the_sync_status_counts_the_callers_subscriptions(db, make_user, as_user assert (body["subscriptions"], body["channels_total"]) == (1, 1) mine = as_user(user).get("/api/sync/my-status").json() assert (mine["channels_total"], mine["channels_recent_pending"]) == (1, 1) + + +def test_a_channel_lists_when_it_entered_this_users_list_not_when_the_channel_did( + db, make_user, as_user +): + from datetime import datetime, timezone + + user = make_user() + db.add(Channel(id="UCa", title="A", created_at=datetime(2026, 6, 10, tzinfo=timezone.utc))) + db.flush() + db.add( + Subscription( + user_id=user.id, + channel_id="UCa", + created_at=datetime(2026, 10, 12, 8, 30, tzinfo=timezone.utc), + ) + ) + db.commit() + listed = as_user(user).get("/api/channels").json() + assert [(c["id"], c["added_at"]) for c in listed] == [("UCa", "2026-10-12T08:30:00+00:00")] diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index 4cc834fa..2b45a4a6 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -82,6 +82,7 @@ "stored": "Stored", "subs": "Subscribers", "lastUpload": "Last upload", + "added": "Added", "length": "Length", "tags": "Tags", "actions": "Actions" diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index 5ff9c159..a800caf5 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -82,6 +82,7 @@ "stored": "Tárolt", "subs": "Feliratkozók", "lastUpload": "Utolsó feltöltés", + "added": "Hozzáadva", "length": "Hossz", "tags": "Címkék", "actions": "Műveletek" diff --git a/frontend/src/lib/api/channels.ts b/frontend/src/lib/api/channels.ts index c3bd4b3f..7ca3c755 100644 --- a/frontend/src/lib/api/channels.ts +++ b/frontend/src/lib/api/channels.ts @@ -73,6 +73,9 @@ export interface ManagedChannel { description: string | null; stored_videos: number; last_video_at: string | null; + /** When the follow reached this list: the subscription import that first saw it, or a + * subscribe made here. */ + added_at: string; total_duration_seconds: number; priority: number; hidden: boolean; diff --git a/frontend/src/modules/channels/AboutCell.dom.test.tsx b/frontend/src/modules/channels/AboutCell.dom.test.tsx new file mode 100644 index 00000000..c1702427 --- /dev/null +++ b/frontend/src/modules/channels/AboutCell.dom.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +// +// The About cell's card opened the instant a pointer crossed the cell, so scrolling the table over +// a column of them popped card after card. It waits for the pointer to rest on the cell now, and a +// scroll puts an open one away. +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, fireEvent, act, cleanup } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import AboutCell from "./AboutCell"; +import { api } from "../../lib/api"; + +beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(api, "channelDetail").mockReturnValue(new Promise(() => {})); +}); +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +function mount() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + return screen.getByText("An element of truth"); +} +// Present in the DOM is enough: it paints once floating-ui has placed it, which a fake clock never lets happen. +const card = () => document.querySelector('[role="tooltip"]'); +const wait = (ms: number) => act(() => vi.advanceTimersByTime(ms)); +const enter = (el: HTMLElement) => + act(() => { + fireEvent.pointerEnter(el); + fireEvent.mouseEnter(el); + fireEvent.mouseMove(el); + }); + +describe("AboutCell — the hover card", () => { + it("does not open for a pointer passing over the cell", async () => { + const cell = mount(); + enter(cell); + await wait(300); + expect(card()).toBeNull(); + fireEvent.mouseLeave(cell); + await wait(600); + expect(card()).toBeNull(); + }); + + it("opens once the pointer rests on it", async () => { + const cell = mount(); + enter(cell); + await wait(550); + expect(card()).not.toBeNull(); + }); + + it("goes away when the table scrolls", async () => { + const cell = mount(); + enter(cell); + await wait(550); + expect(card()).not.toBeNull(); + act(() => void window.dispatchEvent(new Event("scroll"))); + await wait(50); + expect(card()).toBeNull(); + }); +}); diff --git a/frontend/src/modules/channels/AboutCell.tsx b/frontend/src/modules/channels/AboutCell.tsx index 955bdc57..989997e5 100644 --- a/frontend/src/modules/channels/AboutCell.tsx +++ b/frontend/src/modules/channels/AboutCell.tsx @@ -19,6 +19,9 @@ import { SURFACE } from "../../components/ui/surfaces"; * The card keeps the platform's default hover role (`tooltip`); making this rich, interactive panel * properly keyboard-reachable belongs to the focus/a11y work, not to the mechanical migration. */ +// How long the pointer rests on a cell before its card opens. +const ABOUT_REST_MS = 500; + export default function AboutCell({ text, channelId }: { text: string | null; channelId: string }) { const preview = (text ?? "").trim(); const [open, setOpen] = useState(false); @@ -28,8 +31,11 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch onOpenChange: setOpen, mode: "hover", offset: 6, - // Pop instantly like the old cell did; the close grace lets the cursor reach the card. - hoverDelay: { open: 0, close: 160 }, + // Opens once the pointer rests on the cell, not as it crosses one: scrolling the table under a + // still pointer, or moving down the column, used to pop card after card. The close grace lets + // the cursor reach the card; a scroll puts an open one away. + hoverDelay: { open: ABOUT_REST_MS, close: 160 }, + dismissOnScroll: true, // Cap the panel to the space between the trigger and the viewport edge (publishes --fit-max-h) so a // long About never runs its top off-screen — at high browser zoom the short viewport made a fixed // max-height overflow above the top edge, where the scroll container can't reach. diff --git a/frontend/src/modules/channels/channelColumns.dom.test.tsx b/frontend/src/modules/channels/channelColumns.dom.test.tsx index a6ebbd23..977f10b5 100644 --- a/frontend/src/modules/channels/channelColumns.dom.test.tsx +++ b/frontend/src/modules/channels/channelColumns.dom.test.tsx @@ -13,6 +13,8 @@ import { } from "./channelColumns"; import { NO_TAG_SENTINEL, type ManagedChannel, type Tag } from "../../lib/api"; import type { TFunction } from "i18next"; +import { render, screen } from "@testing-library/react"; +import { formatDate } from "../../lib/format"; const ch = (over: Partial): ManagedChannel => ({ id: "c", @@ -24,6 +26,7 @@ const ch = (over: Partial): ManagedChannel => ({ description: null, stored_videos: 0, last_video_at: null, + added_at: "2026-06-10T08:00:00+00:00", total_duration_seconds: 0, priority: 0, hidden: false, @@ -147,3 +150,39 @@ describe("buildChannelColumns — the row's end", () => { expect(cols[cols.length - 1]!.render(ch({ id: "UCz" }))).toBe("more:UCz"); }); }); + +describe("buildChannelColumns — Added", () => { + const noop = () => {}; + const cols = buildChannelColumns({ + t: ((k: string) => k) as never, + lang: "en", + prioOptions: [], + userTags: [], + bumpPriority: noop, + setPriority: noop, + more: () => null, + onView: noop, + toggleTag: noop, + createAndAttachTag: noop, + onFilterByTag: noop, + suggestionsFor: () => [], + onAcceptSuggestion: noop, + onDismissSuggestion: noop, + }); + const added = cols.find((c) => c.key === "added"); + + it("follows Last upload, and sorts by when the follow reached the list", () => { + expect(added?.header).toBe("channels.cols.added"); + const keys = cols.map((c) => c.key); + expect(keys.indexOf("added")).toBe(keys.indexOf("lastUpload") + 1); + expect(added?.sortable).toBe(true); + const early = ch({ added_at: "2026-06-10T08:00:00+00:00" }); + const late = ch({ added_at: "2026-10-12T08:00:00+00:00" }); + expect(added!.sortValue!(late) as number).toBeGreaterThan(added!.sortValue!(early) as number); + }); + + it("shows the day, not a relative time", () => { + render(<>{added!.render(ch({ added_at: "2026-10-12T08:00:00+00:00" }))}); + expect(screen.getByText(formatDate("2026-10-12T08:00:00+00:00", "en"))).toBeTruthy(); + }); +}); diff --git a/frontend/src/modules/channels/channelColumns.tsx b/frontend/src/modules/channels/channelColumns.tsx index 499f3ee3..1c8899c0 100644 --- a/frontend/src/modules/channels/channelColumns.tsx +++ b/frontend/src/modules/channels/channelColumns.tsx @@ -2,7 +2,7 @@ import type { ComponentProps, ReactNode } from "react"; import type { TFunction } from "i18next"; import { NO_TAG_SENTINEL, type ManagedChannel, type Tag } from "../../lib/api"; import { tagPickLabel, type TagList } from "../../lib/tagPicks"; -import { formatCountOrDash, formatTotalHours } from "../../lib/format"; +import { formatCountOrDash, formatDate, formatTotalHours } from "../../lib/format"; import type { Column } from "../../components/ui/DataTable"; import { PriorityStepper } from "../../components/ui/PriorityStepper"; import ChannelLink from "./ChannelLink"; @@ -204,6 +204,20 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column (c.last_video_at ? new Date(c.last_video_at).getTime() : 0), render: (c) => , }, + { + key: "added", + header: t("channels.cols.added"), + nowrap: true, + // The day, not a relative time: beside Last upload two "… ago" columns read as one, and when + // a channel was added is a fixed fact. Room for "2026. okt. 12." and a sort arrow. + width: "7.5rem", + tabletHidden: true, + sortable: true, + sortValue: (c) => new Date(c.added_at).getTime(), + render: (c) => ( + {formatDate(c.added_at, lang)} + ), + }, { key: "length", header: t("channels.cols.length"), diff --git a/frontend/src/modules/channels/useChannelActions.dom.test.tsx b/frontend/src/modules/channels/useChannelActions.dom.test.tsx index 108ccfa8..66d0bd32 100644 --- a/frontend/src/modules/channels/useChannelActions.dom.test.tsx +++ b/frontend/src/modules/channels/useChannelActions.dom.test.tsx @@ -59,6 +59,7 @@ const ch = (over: Partial = {}): ManagedChannel => ({ description: null, stored_videos: 0, last_video_at: null, + added_at: "2026-06-10T08:00:00+00:00", total_duration_seconds: 0, priority: 0, hidden: false,