From ce171fa9542e04545d8220d3d844e3d7c34ed196 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 25 Sep 2026 23:43:26 +0200 Subject: [PATCH] fix(feed): a custom date bound applies once its year is a plausible upload year, not per typed digit (E28) A native date field reports a complete date on every keystroke of its year (0002, 0020, 0202, 2026), so one typed year refetched the feed four times. The field holds a draft while focused; leaving it empty clears the bound, leaving it half-typed restores it. --- .../components/DateFilterSelect.dom.test.tsx | 58 ++++++++++++++ frontend/src/components/DateFilterSelect.tsx | 80 ++++++++++++------- frontend/src/lib/dateFilter.ts | 12 +++ 3 files changed, 119 insertions(+), 31 deletions(-) create mode 100644 frontend/src/components/DateFilterSelect.dom.test.tsx diff --git a/frontend/src/components/DateFilterSelect.dom.test.tsx b/frontend/src/components/DateFilterSelect.dom.test.tsx new file mode 100644 index 00000000..112bf148 --- /dev/null +++ b/frontend/src/components/DateFilterSelect.dom.test.tsx @@ -0,0 +1,58 @@ +// @vitest-environment jsdom +// +// E28 (owner): a native date field reports a complete, valid date on every keystroke of its year, so +// typing 2026 applied 0002, 0020, 0202 and 2026 — four feed refetches for one year. +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, cleanup, screen, fireEvent } from "@testing-library/react"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), + }; +}); + +import DateFilterSelect from "./DateFilterSelect"; +import { type FeedFilters } from "../lib/api"; + +afterEach(cleanup); + +function mount(filters: Partial) { + const setFilters = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: /sidebar\.datePresetLabel/ })); + const [from] = document.querySelectorAll('input[type="date"]'); + return { setFilters, from: from! }; +} + +describe("a custom range bound", () => { + it("is applied once, when the typed year becomes a plausible upload year", () => { + const { setFilters, from } = mount({ dateTo: "2026-09-01" }); + for (const day of ["0002-01-15", "0020-01-15", "0202-01-15", "2026-01-15"]) { + fireEvent.change(from, { target: { value: day } }); + } + expect(setFilters).toHaveBeenCalledTimes(1); + expect(setFilters.mock.calls[0]![0]).toMatchObject({ dateFrom: "2026-01-15" }); + }); + + it("is cleared when the field is left empty, and kept when it is left half-typed", () => { + const { setFilters, from } = mount({ dateFrom: "2026-01-15" }); + fireEvent.change(from, { target: { value: "0202-01-15" } }); + fireEvent.blur(from); + expect(setFilters).not.toHaveBeenCalled(); + expect(from.value).toBe("2026-01-15"); + + fireEvent.change(from, { target: { value: "" } }); + expect(setFilters).not.toHaveBeenCalled(); + fireEvent.blur(from); + expect(setFilters).toHaveBeenCalledTimes(1); + expect(setFilters.mock.calls[0]![0]).toMatchObject({ dateFrom: undefined }); + }); +}); diff --git a/frontend/src/components/DateFilterSelect.tsx b/frontend/src/components/DateFilterSelect.tsx index b15fccb5..5f082f55 100644 --- a/frontend/src/components/DateFilterSelect.tsx +++ b/frontend/src/components/DateFilterSelect.tsx @@ -4,7 +4,12 @@ import { CalendarDays, Check } from "lucide-react"; import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; import { type FeedFilters } from "../lib/api"; -import { deriveDateKey, selectDateChip, type DatePreset } from "../lib/dateFilter"; +import { + deriveDateKey, + isCommittableDay, + selectDateChip, + type DatePreset, +} from "../lib/dateFilter"; import { Button } from "./ui/Button"; import { SelectTrigger } from "./ui/SelectTrigger"; import { Input } from "./ui/Input"; @@ -134,45 +139,24 @@ export default function DateFilterSelect({ })} - {/* Reveal-on-select: the from/to fields appear once Custom is the chosen option. The inputs - stop non-Escape keys so the date-segment arrows act on the field, not the menu's list nav. */} + {/* Reveal-on-select: the from/to fields appear once Custom is the chosen option. */} {key === "custom" && (
@@ -191,3 +175,37 @@ export default function DateFilterSelect({ ); } + +// One bound of the custom range. What the field shows is a draft while it has focus (`draft ?? value`): +// the bound is applied only once the draft is a plausible day (`isCommittableDay` says why), so typing a +// year no longer refetches the feed per digit; a date picked from the calendar applies at once. Leaving +// the field settles it: emptied, the bound is cleared; half-typed or implausible, it shows the bound again. +// It stops non-Escape keys so the date-segment arrows act on the field, not the menu's list navigation. +function DayField({ + value, + onCommit, +}: { + value: string | undefined; + onCommit: (day: string | undefined) => void; +}) { + const [draft, setDraft] = useState(); + return ( + { + if (e.key !== "Escape") e.stopPropagation(); + }} + onChange={(e) => { + const day = e.target.value; + setDraft(day); + if (isCommittableDay(day) && day !== value) onCommit(day); + }} + onBlur={() => { + if (draft === "" && value) onCommit(undefined); + setDraft(undefined); + }} + /> + ); +} diff --git a/frontend/src/lib/dateFilter.ts b/frontend/src/lib/dateFilter.ts index a9dd594b..57db0d2b 100644 --- a/frontend/src/lib/dateFilter.ts +++ b/frontend/src/lib/dateFilter.ts @@ -61,3 +61,15 @@ export function selectDateChip( customOpen: false, }; } + +// The first year an upload can have: YouTube opened in 2005. +const FIRST_UPLOAD_YEAR = 2005; + +/** Whether a native date field's value is a bound worth filtering by. The field reports a COMPLETE + * date on every keystroke of its year — typing 2026 passes 0002, 0020 and 0202, each a valid date to + * the browser — so applying every value refetched the feed four times for one year (E28). A bound is + * applied once it is a plausible upload day; a half-typed field reports "" and is not one either. */ +export function isCommittableDay(s: string): boolean { + const m = /^(\d{4})-\d{2}-\d{2}$/.exec(s); + return !!m && Number(m[1]) >= FIRST_UPLOAD_YEAR; +}