From 9cbf7d228db3dd02884539fcc706c8747b3b2451 Mon Sep 17 00:00:00 2001 From: Sandy Date: Wed, 24 Jun 2026 09:49:05 +0530 Subject: [PATCH] feat(web): add a date-range filter to the Tasks page Adds a "Dates" popover to the Tasks header that filters both Board and List views by a timestamp range. The control carries a field selector (Updated / Created / Closed / Due, default Updated), From/To native date inputs that bound each other (To >= From), and quick presets (Today, 7d, 30d, Month). The active range surfaces as a chip on the trigger and folds into the existing filter count + Clear affordance. Filtering is a single read-time predicate added to visibleTasks, so every view that reads it narrows automatically. Tasks missing the chosen timestamp (e.g. an open task has no closed_at) fall out when a range is set, so "Closed in June" never lists unclosed work. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/app/routes/tasks.tsx | 17 ++- apps/web/app/tasks/date-filter.tsx | 210 +++++++++++++++++++++++++++++ 2 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 apps/web/app/tasks/date-filter.tsx diff --git a/apps/web/app/routes/tasks.tsx b/apps/web/app/routes/tasks.tsx index 53cbcc6b..d9af2fec 100644 --- a/apps/web/app/routes/tasks.tsx +++ b/apps/web/app/routes/tasks.tsx @@ -26,6 +26,13 @@ import { FilterCombobox } from "../tasks/filter-combobox"; import { TasksBoard } from "../tasks/board"; import { TaskDetailPanel, type AgentOption } from "../tasks/detail"; import { TaskListRow } from "../tasks/row"; +import { + DateRangeFilter, + EMPTY_DATE_RANGE, + dateRangeActive, + taskInDateRange, + type DateRange, +} from "../tasks/date-filter"; import { STATUS_LABEL, STATUS_ORDER, @@ -74,6 +81,7 @@ function TasksPage() { const [teamFilter, setTeamFilter] = useState("all"); const [assigneeFilter, setAssigneeFilter] = useState("all"); const [query, setQuery] = useState(""); + const [dateRange, setDateRange] = useState(EMPTY_DATE_RANGE); const [viewMode, setViewMode] = useState(() => { if (typeof window === "undefined") return "board"; const stored = window.localStorage.getItem(VIEW_MODE_STORAGE_KEY); @@ -323,12 +331,14 @@ function TasksPage() { const filtersActive = teamFilter !== "all" || assigneeFilter !== "all" || - query.trim() !== ""; + query.trim() !== "" || + dateRangeActive(dateRange); const clearFilters = () => { setTeamFilter("all"); setAssigneeFilter("all"); setQuery(""); + setDateRange((r) => ({ ...r, from: null, to: null })); }; const visibleTasks = useMemo(() => { @@ -337,6 +347,7 @@ function TasksPage() { if (teamFilter !== "all" && t.team !== teamFilter) return false; if (assigneeFilter !== "all" && t.assignee !== assigneeFilter) return false; + if (!taskInDateRange((field) => t[field], dateRange)) return false; if (q) { const hay = `${t.title} #${t.id} ${t.assignee} ${t.team ?? ""} ${ t.body ?? "" @@ -345,7 +356,7 @@ function TasksPage() { } return true; }); - }, [tasks, teamFilter, assigneeFilter, query]); + }, [tasks, teamFilter, assigneeFilter, query, dateRange]); const grouped = useMemo(() => { const out = new Map(); @@ -456,6 +467,8 @@ function TasksPage() { /> )} + + {/* Eats the slack so the count + view toggle pin right while the search caps at max-w-xs. */}
diff --git a/apps/web/app/tasks/date-filter.tsx b/apps/web/app/tasks/date-filter.tsx new file mode 100644 index 00000000..2bea63af --- /dev/null +++ b/apps/web/app/tasks/date-filter.tsx @@ -0,0 +1,210 @@ +import { useState } from "react"; +import { CalendarRange, ChevronsUpDown } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/app/components/ui/popover"; +import { cn } from "@/app/lib/utils"; + +// Which task timestamp the range filters on. Updated is the default +// because every task carries it — Closed/Due empty out non-terminal / +// undated tasks, which is intentional but surprising as a default. +export type DateField = "updated_at" | "created_at" | "closed_at" | "due_at"; + +export interface DateRange { + field: DateField; + from: string | null; // YYYY-MM-DD (local), inclusive start-of-day + to: string | null; // YYYY-MM-DD (local), inclusive end-of-day +} + +export const EMPTY_DATE_RANGE: DateRange = { + field: "updated_at", + from: null, + to: null, +}; + +export const dateRangeActive = (r: DateRange): boolean => !!(r.from || r.to); + +const FIELDS: { value: DateField; label: string }[] = [ + { value: "updated_at", label: "Updated" }, + { value: "created_at", label: "Created" }, + { value: "closed_at", label: "Closed" }, + { value: "due_at", label: "Due" }, +]; + +const fieldLabel = (f: DateField) => + FIELDS.find((x) => x.value === f)?.label ?? f; + +// Local YYYY-MM-DD for `d`, matching the value space of . +function toISO(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +function daysAgo(n: number): string { + const d = new Date(); + d.setDate(d.getDate() - n); + return toISO(d); +} + +function startOfMonth(): string { + const d = new Date(); + d.setDate(1); + return toISO(d); +} + +const PRESETS: { label: string; range: () => { from: string; to: string } }[] = [ + { label: "Today", range: () => ({ from: toISO(new Date()), to: toISO(new Date()) }) }, + { label: "7d", range: () => ({ from: daysAgo(6), to: toISO(new Date()) }) }, + { label: "30d", range: () => ({ from: daysAgo(29), to: toISO(new Date()) }) }, + { label: "Month", range: () => ({ from: startOfMonth(), to: toISO(new Date()) }) }, +]; + +// Compact MM-DD for the trigger chip — stays in the engraved-ISO register +// the rest of the task surface uses, rather than a friendly "Jun 24". +const chip = (d: string) => d.slice(5); + +function triggerLabel(r: DateRange): string { + const f = fieldLabel(r.field); + if (r.from && r.to) return `${f} ${chip(r.from)}→${chip(r.to)}`; + if (r.from) return `${f} ≥ ${chip(r.from)}`; + if (r.to) return `${f} ≤ ${chip(r.to)}`; + return "Dates"; +} + +export function DateRangeFilter({ + value, + onChange, + className, +}: { + value: DateRange; + onChange: (r: DateRange) => void; + className?: string; +}) { + const [open, setOpen] = useState(false); + const active = dateRangeActive(value); + + return ( + + + + + +
+
+ Filter by +
+
+ {FIELDS.map((f, i) => ( + + ))} +
+
+ +
+ + +
+ +
+
+ {PRESETS.map((p) => ( + + ))} +
+ {active && ( + + )} +
+
+
+ ); +} + +// Read-time predicate: is `task` inside the active range? Tasks missing the +// chosen timestamp (e.g. an open task has no closed_at) fall out when a +// range is set — intended, so "Closed in June" never lists unclosed work. +export function taskInDateRange( + dateOf: (field: DateField) => string | null, + r: DateRange +): boolean { + if (!dateRangeActive(r)) return true; + const raw = dateOf(r.field); + if (!raw) return false; + const ms = new Date(raw).getTime(); + if (!Number.isFinite(ms)) return false; + if (r.from && ms < new Date(`${r.from}T00:00:00`).getTime()) return false; + if (r.to && ms > new Date(`${r.to}T23:59:59.999`).getTime()) return false; + return true; +}