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 { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||||
import { type FeedFilters } from "../lib/api";
|
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 { Button } from "./ui/Button";
|
||||||
import { SelectTrigger } from "./ui/SelectTrigger";
|
import { SelectTrigger } from "./ui/SelectTrigger";
|
||||||
import { Input } from "./ui/Input";
|
import { Input } from "./ui/Input";
|
||||||
@@ -134,45 +139,24 @@ export default function DateFilterSelect({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reveal-on-select: the from/to fields appear once Custom is the chosen option. The inputs
|
{/* Reveal-on-select: the from/to fields appear once Custom is the chosen option. */}
|
||||||
stop non-Escape keys so the date-segment arrows act on the field, not the menu's list nav. */}
|
|
||||||
{key === "custom" && (
|
{key === "custom" && (
|
||||||
<div className="mt-1 flex flex-col gap-1.5 border-t border-border px-1 pt-2">
|
<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">
|
<label className="flex items-center justify-between gap-2 text-xs">
|
||||||
<span className="text-muted">{t("sidebar.from")}</span>
|
<span className="text-muted">{t("sidebar.from")}</span>
|
||||||
<Input
|
<DayField
|
||||||
size="sm"
|
value={filters.dateFrom}
|
||||||
type="date"
|
onCommit={(v) =>
|
||||||
value={filters.dateFrom ?? ""}
|
setFilters({ ...filters, dateFrom: v, maxAgeDays: undefined, year: undefined })
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key !== "Escape") e.stopPropagation();
|
|
||||||
}}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFilters({
|
|
||||||
...filters,
|
|
||||||
dateFrom: e.target.value || undefined,
|
|
||||||
maxAgeDays: undefined,
|
|
||||||
year: undefined,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center justify-between gap-2 text-xs">
|
<label className="flex items-center justify-between gap-2 text-xs">
|
||||||
<span className="text-muted">{t("sidebar.to")}</span>
|
<span className="text-muted">{t("sidebar.to")}</span>
|
||||||
<Input
|
<DayField
|
||||||
size="sm"
|
value={filters.dateTo}
|
||||||
type="date"
|
onCommit={(v) =>
|
||||||
value={filters.dateTo ?? ""}
|
setFilters({ ...filters, dateTo: v, maxAgeDays: undefined, year: undefined })
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key !== "Escape") e.stopPropagation();
|
|
||||||
}}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFilters({
|
|
||||||
...filters,
|
|
||||||
dateTo: e.target.value || undefined,
|
|
||||||
maxAgeDays: undefined,
|
|
||||||
year: undefined,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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,
|
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