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,