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.
This commit is contained in:
2026-11-01 01:30:30 +01:00
parent e06c0688d9
commit 85fd9b6704
10 changed files with 161 additions and 3 deletions
@@ -82,6 +82,7 @@
"stored": "Stored",
"subs": "Subscribers",
"lastUpload": "Last upload",
"added": "Added",
"length": "Length",
"tags": "Tags",
"actions": "Actions"
@@ -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"
+3
View File
@@ -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;
@@ -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(
<QueryClientProvider client={qc}>
<AboutCell text="An element of truth" channelId="UC1" />
</QueryClientProvider>,
);
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();
});
});
+8 -2
View File
@@ -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.
@@ -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>): ManagedChannel => ({
id: "c",
@@ -24,6 +26,7 @@ const ch = (over: Partial<ManagedChannel>): 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();
});
});
@@ -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<ManagedChan
sortValue: (c) => (c.last_video_at ? new Date(c.last_video_at).getTime() : 0),
render: (c) => <RelativeTime at={c.last_video_at} empty="—" className="text-muted" />,
},
{
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) => (
<span className="text-muted tabular-nums">{formatDate(c.added_at, lang)}</span>
),
},
{
key: "length",
header: t("channels.cols.length"),
@@ -59,6 +59,7 @@ const ch = (over: Partial<ManagedChannel> = {}): 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,