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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user