diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index 30456be4..dd9d2467 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -10,6 +10,7 @@ import { copyText } from "../lib/clipboard";
import { notify } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced";
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
+import { applyDateChip, deriveDateKey } from "../lib/dateFilter";
import TagManager from "./TagManager";
import SavedViewsWidget from "./SavedViewsWidget";
import SidePanel from "./SidePanel";
@@ -152,17 +153,8 @@ export default function Sidebar({
case "channel":
return ;
case "date": {
- // Single-select across the presets + Custom: a preset is active only when its maxAge matches
- // and no custom range is open; otherwise Custom owns the selection (or none).
- const activePresetDays = DATE_PRESETS.find(
- (p) => filters.maxAgeDays === p.days && !customDates,
- )?.days;
- const dateKey =
- customDates || filters.dateFrom || filters.dateTo
- ? "custom"
- : activePresetDays != null
- ? String(activePresetDays)
- : "";
+ // Single-select across the presets + Custom; pure logic lives in lib/dateFilter.
+ const dateKey = deriveDateKey(filters, customDates, DATE_PRESETS);
return (
<>
{
- if (key === "custom") {
- const next = !customDates;
- setCustomDates(next);
- if (next) setFilters({ ...filters, maxAgeDays: undefined });
- return;
- }
- const days = Number(key);
- const activePreset = filters.maxAgeDays === days && !customDates;
- setFilters({
- ...filters,
- maxAgeDays: activePreset ? undefined : days,
- dateFrom: undefined,
- dateTo: undefined,
- });
+ const change = applyDateChip(key, filters, customDates);
+ if (change.customDates !== undefined) setCustomDates(change.customDates);
+ if (change.filters) setFilters(change.filters);
}}
/>
diff --git a/frontend/src/lib/dateFilter.test.ts b/frontend/src/lib/dateFilter.test.ts
new file mode 100644
index 00000000..b793dad8
--- /dev/null
+++ b/frontend/src/lib/dateFilter.test.ts
@@ -0,0 +1,88 @@
+import { describe, it, expect } from "vitest";
+import { applyDateChip, deriveDateKey, type DatePreset } from "./dateFilter";
+import { type FeedFilters } from "./api";
+
+const PRESETS: DatePreset[] = [
+ { days: 1, key: "24h" },
+ { days: 7, key: "1week" },
+ { days: 30, key: "1month" },
+ { days: 180, key: "6months" },
+ { days: 365, key: "1year" },
+];
+
+const base = (over: Partial = {}): FeedFilters => ({
+ tags: [],
+ tagMode: "or",
+ q: "",
+ sort: "date",
+ scope: "my",
+ includeNormal: true,
+ includeShorts: true,
+ includeLive: true,
+ show: "unwatched",
+ channelIds: [],
+ ...over,
+});
+
+describe("deriveDateKey", () => {
+ it("is empty when no date filter is set", () => {
+ expect(deriveDateKey(base(), false, PRESETS)).toBe("");
+ });
+
+ it("is the preset day-count when maxAgeDays matches a preset", () => {
+ expect(deriveDateKey(base({ maxAgeDays: 7 }), false, PRESETS)).toBe("7");
+ });
+
+ it("is empty when maxAgeDays matches no preset (e.g. a shared-link value)", () => {
+ expect(deriveDateKey(base({ maxAgeDays: 45 }), false, PRESETS)).toBe("");
+ });
+
+ it("is 'custom' when the custom editor is open", () => {
+ expect(deriveDateKey(base(), true, PRESETS)).toBe("custom");
+ });
+
+ it("is 'custom' when a from/to range is set, even over a stale maxAgeDays", () => {
+ expect(deriveDateKey(base({ dateFrom: "2026-01-01", maxAgeDays: 7 }), false, PRESETS)).toBe(
+ "custom",
+ );
+ });
+});
+
+describe("applyDateChip", () => {
+ it("picking a preset sets maxAgeDays and clears any custom range", () => {
+ const change = applyDateChip(
+ "7",
+ base({ dateFrom: "2026-01-01", dateTo: "2026-02-01" }),
+ false,
+ );
+ expect(change.customDates).toBeUndefined();
+ expect(change.filters).toMatchObject({ maxAgeDays: 7, dateFrom: undefined, dateTo: undefined });
+ });
+
+ it("re-picking the active preset toggles it off", () => {
+ const change = applyDateChip("7", base({ maxAgeDays: 7 }), false);
+ expect(change.filters?.maxAgeDays).toBeUndefined();
+ });
+
+ it("a preset is not 'active' while a custom range is open, so it sets rather than toggling off", () => {
+ const change = applyDateChip("7", base({ maxAgeDays: 7 }), true);
+ expect(change.filters?.maxAgeDays).toBe(7);
+ });
+
+ it("opening Custom sets the flag and clears a preset's maxAgeDays", () => {
+ const change = applyDateChip("custom", base({ maxAgeDays: 7 }), false);
+ expect(change.customDates).toBe(true);
+ expect(change.filters).toMatchObject({ maxAgeDays: undefined });
+ });
+
+ it("closing Custom clears the flag and leaves filters untouched", () => {
+ const change = applyDateChip("custom", base({ maxAgeDays: 7 }), true);
+ expect(change.customDates).toBe(false);
+ expect(change.filters).toBeUndefined();
+ });
+
+ it("preserves unrelated filter fields when picking a preset", () => {
+ const change = applyDateChip("30", base({ q: "hello", tags: [1, 2] }), false);
+ expect(change.filters).toMatchObject({ q: "hello", tags: [1, 2], maxAgeDays: 30 });
+ });
+});
diff --git a/frontend/src/lib/dateFilter.ts b/frontend/src/lib/dateFilter.ts
new file mode 100644
index 00000000..bbb806ff
--- /dev/null
+++ b/frontend/src/lib/dateFilter.ts
@@ -0,0 +1,51 @@
+import { type FeedFilters } from "./api";
+
+// Pure date-filter selection logic for the feed sidebar's "Upload date" chip row, lifted out of the
+// component so the single-select derivation + the preset/custom dispatch can be unit-tested without
+// mounting Sidebar (same split as columnSort.ts for sort). The row is single-select across the day
+// presets plus a "Custom" range chip.
+
+export interface DatePreset {
+ days: number;
+ key: string;
+}
+
+/** Which date chip is active: a preset's day-count (as a string), "custom", or "" (none). A custom
+ * range (the editor is open, or a from/to is set) owns the selection; otherwise a preset matches only
+ * when no custom range is active; a maxAge that matches no preset (e.g. a shared-link value) is none. */
+export function deriveDateKey(
+ filters: Pick,
+ customDates: boolean,
+ presets: readonly DatePreset[],
+): string {
+ if (customDates || filters.dateFrom || filters.dateTo) return "custom";
+ const preset = presets.find((p) => filters.maxAgeDays === p.days);
+ return preset ? String(preset.days) : "";
+}
+
+/** The state change when a date chip is picked. A preset is single-select with toggle-off (re-picking
+ * the active preset clears it) and clears any custom range; "custom" toggles the range editor and,
+ * when opened, clears a preset. Returns the next `filters` (only when they change) and/or the next
+ * `customDates` flag (only when it changes) so the caller applies exactly what moved. */
+export function applyDateChip(
+ key: string,
+ filters: FeedFilters,
+ customDates: boolean,
+): { filters?: FeedFilters; customDates?: boolean } {
+ if (key === "custom") {
+ const next = !customDates;
+ return next
+ ? { customDates: true, filters: { ...filters, maxAgeDays: undefined } }
+ : { customDates: false };
+ }
+ const days = Number(key);
+ const activePreset = filters.maxAgeDays === days && !customDates;
+ return {
+ filters: {
+ ...filters,
+ maxAgeDays: activePreset ? undefined : days,
+ dateFrom: undefined,
+ dateTo: undefined,
+ },
+ };
+}