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.
This commit is contained in:
2026-09-25 23:43:26 +02:00
parent 113da76c36
commit ce171fa954
3 changed files with 119 additions and 31 deletions
@@ -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<typeof import("react-i18next")>();
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<FeedFilters>) {
const setFilters = vi.fn();
render(
<DateFilterSelect
filters={filters as FeedFilters}
setFilters={setFilters}
presets={[{ days: 7, key: "1week" }]}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /sidebar\.datePresetLabel/ }));
const [from] = document.querySelectorAll<HTMLInputElement>('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 });
});
});
+49 -31
View File
@@ -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({
})}
</div>
{/* 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" && (
<div className="mt-1 flex flex-col gap-1.5 border-t border-border px-1 pt-2">
<label className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted">{t("sidebar.from")}</span>
<Input
size="sm"
type="date"
value={filters.dateFrom ?? ""}
onKeyDown={(e) => {
if (e.key !== "Escape") e.stopPropagation();
}}
onChange={(e) =>
setFilters({
...filters,
dateFrom: e.target.value || undefined,
maxAgeDays: undefined,
year: undefined,
})
<DayField
value={filters.dateFrom}
onCommit={(v) =>
setFilters({ ...filters, dateFrom: v, maxAgeDays: undefined, year: undefined })
}
/>
</label>
<label className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted">{t("sidebar.to")}</span>
<Input
size="sm"
type="date"
value={filters.dateTo ?? ""}
onKeyDown={(e) => {
if (e.key !== "Escape") e.stopPropagation();
}}
onChange={(e) =>
setFilters({
...filters,
dateTo: e.target.value || undefined,
maxAgeDays: undefined,
year: undefined,
})
<DayField
value={filters.dateTo}
onCommit={(v) =>
setFilters({ ...filters, dateTo: v, maxAgeDays: undefined, year: undefined })
}
/>
</label>
@@ -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<string>();
return (
<Input
size="sm"
type="date"
value={draft ?? value ?? ""}
onKeyDown={(e) => {
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);
}}
/>
);
}
+12
View File
@@ -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;
}