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:
@@ -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,
|
"details_synced": ch.details_synced_at is not None,
|
||||||
"recent_synced": ch.recent_synced_at is not None,
|
"recent_synced": ch.recent_synced_at is not None,
|
||||||
"backfill_done": ch.backfill_done,
|
"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
|
for sub, ch in rows
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
assert (body["subscriptions"], body["channels_total"]) == (1, 1)
|
||||||
mine = as_user(user).get("/api/sync/my-status").json()
|
mine = as_user(user).get("/api/sync/my-status").json()
|
||||||
assert (mine["channels_total"], mine["channels_recent_pending"]) == (1, 1)
|
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")]
|
||||||
|
|||||||
@@ -82,6 +82,7 @@
|
|||||||
"stored": "Stored",
|
"stored": "Stored",
|
||||||
"subs": "Subscribers",
|
"subs": "Subscribers",
|
||||||
"lastUpload": "Last upload",
|
"lastUpload": "Last upload",
|
||||||
|
"added": "Added",
|
||||||
"length": "Length",
|
"length": "Length",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"actions": "Actions"
|
"actions": "Actions"
|
||||||
|
|||||||
@@ -82,6 +82,7 @@
|
|||||||
"stored": "Tárolt",
|
"stored": "Tárolt",
|
||||||
"subs": "Feliratkozók",
|
"subs": "Feliratkozók",
|
||||||
"lastUpload": "Utolsó feltöltés",
|
"lastUpload": "Utolsó feltöltés",
|
||||||
|
"added": "Hozzáadva",
|
||||||
"length": "Hossz",
|
"length": "Hossz",
|
||||||
"tags": "Címkék",
|
"tags": "Címkék",
|
||||||
"actions": "Műveletek"
|
"actions": "Műveletek"
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ export interface ManagedChannel {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
stored_videos: number;
|
stored_videos: number;
|
||||||
last_video_at: string | null;
|
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;
|
total_duration_seconds: number;
|
||||||
priority: number;
|
priority: number;
|
||||||
hidden: boolean;
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
* 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.
|
* 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 }) {
|
export default function AboutCell({ text, channelId }: { text: string | null; channelId: string }) {
|
||||||
const preview = (text ?? "").trim();
|
const preview = (text ?? "").trim();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -28,8 +31,11 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
|
|||||||
onOpenChange: setOpen,
|
onOpenChange: setOpen,
|
||||||
mode: "hover",
|
mode: "hover",
|
||||||
offset: 6,
|
offset: 6,
|
||||||
// Pop instantly like the old cell did; the close grace lets the cursor reach the card.
|
// Opens once the pointer rests on the cell, not as it crosses one: scrolling the table under a
|
||||||
hoverDelay: { open: 0, close: 160 },
|
// 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
|
// 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
|
// 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.
|
// max-height overflow above the top edge, where the scroll container can't reach.
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
} from "./channelColumns";
|
} from "./channelColumns";
|
||||||
import { NO_TAG_SENTINEL, type ManagedChannel, type Tag } from "../../lib/api";
|
import { NO_TAG_SENTINEL, type ManagedChannel, type Tag } from "../../lib/api";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { formatDate } from "../../lib/format";
|
||||||
|
|
||||||
const ch = (over: Partial<ManagedChannel>): ManagedChannel => ({
|
const ch = (over: Partial<ManagedChannel>): ManagedChannel => ({
|
||||||
id: "c",
|
id: "c",
|
||||||
@@ -24,6 +26,7 @@ const ch = (over: Partial<ManagedChannel>): ManagedChannel => ({
|
|||||||
description: null,
|
description: null,
|
||||||
stored_videos: 0,
|
stored_videos: 0,
|
||||||
last_video_at: null,
|
last_video_at: null,
|
||||||
|
added_at: "2026-06-10T08:00:00+00:00",
|
||||||
total_duration_seconds: 0,
|
total_duration_seconds: 0,
|
||||||
priority: 0,
|
priority: 0,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
@@ -147,3 +150,39 @@ describe("buildChannelColumns — the row's end", () => {
|
|||||||
expect(cols[cols.length - 1]!.render(ch({ id: "UCz" }))).toBe("more:UCz");
|
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 type { TFunction } from "i18next";
|
||||||
import { NO_TAG_SENTINEL, type ManagedChannel, type Tag } from "../../lib/api";
|
import { NO_TAG_SENTINEL, type ManagedChannel, type Tag } from "../../lib/api";
|
||||||
import { tagPickLabel, type TagList } from "../../lib/tagPicks";
|
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 type { Column } from "../../components/ui/DataTable";
|
||||||
import { PriorityStepper } from "../../components/ui/PriorityStepper";
|
import { PriorityStepper } from "../../components/ui/PriorityStepper";
|
||||||
import ChannelLink from "./ChannelLink";
|
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),
|
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" />,
|
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",
|
key: "length",
|
||||||
header: t("channels.cols.length"),
|
header: t("channels.cols.length"),
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const ch = (over: Partial<ManagedChannel> = {}): ManagedChannel => ({
|
|||||||
description: null,
|
description: null,
|
||||||
stored_videos: 0,
|
stored_videos: 0,
|
||||||
last_video_at: null,
|
last_video_at: null,
|
||||||
|
added_at: "2026-06-10T08:00:00+00:00",
|
||||||
total_duration_seconds: 0,
|
total_duration_seconds: 0,
|
||||||
priority: 0,
|
priority: 0,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user