From 003043a9898bece8d0536a0b19eb8635b53bd84a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 15 Aug 2026 02:27:22 +0200 Subject: [PATCH] test(sidebar): cover the date preset/custom selection logic (R13 S2c) The deep-review flagged that the feed sidebar's new date single-select derivation was the one bit of migrated logic without a test, while its sibling primitives (FilterChipGroup/SegmentedToggle/SortControl) all gained one. Lift the pure logic into lib/dateFilter (deriveDateKey + applyDateChip), same split as columnSort, and cover it: dateKey for none/preset/custom/off-preset-value, the preset toggle-off, the preset-not-active-while-custom-open case, and the Custom open/close + maxAgeDays clearing. Sidebar now calls the helpers; the onChange is behaviour-identical (self-E2E'd: preset on/off, Custom editor). 11 new tests (vitest 136 -> 147). gate green. Branch not merged. --- frontend/src/components/Sidebar.tsx | 31 ++-------- frontend/src/lib/dateFilter.test.ts | 88 +++++++++++++++++++++++++++++ frontend/src/lib/dateFilter.ts | 51 +++++++++++++++++ 3 files changed, 145 insertions(+), 25 deletions(-) create mode 100644 frontend/src/lib/dateFilter.test.ts create mode 100644 frontend/src/lib/dateFilter.ts 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, + }, + }; +}