feat: support relative date view filters
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { FilterBuilder } from './FilterBuilder'
|
||||
import type { FilterGroup } from '../types'
|
||||
@@ -10,6 +10,10 @@ describe('FilterBuilder value inputs', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function renderBuilder(group?: FilterGroup) {
|
||||
const defaultGroup: FilterGroup = {
|
||||
all: [{ field: 'title', op: 'contains', value: '' }],
|
||||
@@ -97,10 +101,10 @@ describe('FilterBuilder value inputs', () => {
|
||||
all: [{ field: 'created', op: 'before', value: '2024-06-01' }],
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('date-value-input')).toHaveValue('2024-06-01')
|
||||
const dateButton = screen.getByTestId('date-picker-trigger')
|
||||
expect(dateButton).toBeInTheDocument()
|
||||
expect(dateButton).toHaveTextContent('Jun 1, 2024')
|
||||
expect(screen.queryByDisplayValue('2024-06-01')).not.toBeInTheDocument()
|
||||
expect(dateButton).toHaveAttribute('title', 'Jun 1, 2024')
|
||||
})
|
||||
|
||||
it('renders date picker placeholder when no date is selected', () => {
|
||||
@@ -108,10 +112,23 @@ describe('FilterBuilder value inputs', () => {
|
||||
all: [{ field: 'created', op: 'after', value: '' }],
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('date-picker-trigger')).toHaveTextContent('Pick a date')
|
||||
expect(screen.getByTestId('date-value-input')).toHaveValue('')
|
||||
expect(screen.getByTestId('date-picker-trigger')).toHaveAttribute('title', 'Pick a date')
|
||||
expect(screen.queryByTestId('filter-regex-toggle')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('allows free-text relative date phrases for date operators', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-04-07T12:00:00Z'))
|
||||
|
||||
renderBuilder({
|
||||
all: [{ field: 'created', op: 'after', value: '10 days ago' }],
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('date-value-input')).toHaveValue('10 days ago')
|
||||
expect(screen.getByTestId('date-picker-trigger')).toHaveAttribute('title', 'Mar 28, 2026')
|
||||
})
|
||||
|
||||
it('shows body field in field dropdown separated from property fields', () => {
|
||||
render(
|
||||
<FilterBuilder
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Plus, X, CalendarBlank, WarningCircle } from '@phosphor-icons/react'
|
||||
import { format, parseISO } from 'date-fns'
|
||||
import { format } from 'date-fns'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
@@ -7,6 +7,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
||||
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { FilterCondition, FilterOp, FilterGroup, FilterNode } from '../types'
|
||||
import { parseDateFilterInput } from '../utils/filterDates'
|
||||
|
||||
const OPERATORS: { value: FilterOp; label: string }[] = [
|
||||
{ value: 'equals', label: 'equals' },
|
||||
@@ -116,28 +117,39 @@ function OperatorSelect({ value, onChange }: {
|
||||
}
|
||||
|
||||
function DateValueInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const parsed = value ? parseISO(value) : undefined
|
||||
const selected = parsed && !isNaN(parsed.getTime()) ? parsed : undefined
|
||||
const selected = value ? parseDateFilterInput(value) ?? undefined : undefined
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
data-testid="date-picker-trigger"
|
||||
className="h-8 flex-1 min-w-0 justify-start gap-2 px-2 text-sm font-normal"
|
||||
>
|
||||
<CalendarBlank size={14} className="shrink-0 text-muted-foreground" />
|
||||
{selected ? format(selected, 'MMM d, yyyy') : <span className="text-muted-foreground">Pick a date</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selected}
|
||||
onSelect={(day) => onChange(day ? format(day, 'yyyy-MM-dd') : '')}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="flex flex-1 min-w-0 items-center gap-1">
|
||||
<Input
|
||||
className="h-8 flex-1 min-w-0 text-sm"
|
||||
placeholder='YYYY-MM-DD or "10 days ago"'
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
data-testid="date-value-input"
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="date-picker-trigger"
|
||||
className="h-8 w-8 shrink-0 px-0"
|
||||
title={selected ? format(selected, 'MMM d, yyyy') : 'Pick a date'}
|
||||
aria-label={selected ? `Open date picker (${format(selected, 'MMM d, yyyy')})` : 'Open date picker'}
|
||||
>
|
||||
<CalendarBlank size={14} className="shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selected}
|
||||
onSelect={(day) => onChange(day ? format(day, 'yyyy-MM-dd') : '')}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
37
src/utils/filterDates.test.ts
Normal file
37
src/utils/filterDates.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { format } from 'date-fns'
|
||||
import { parseDateFilterInput, toDateFilterTimestamp } from './filterDates'
|
||||
|
||||
describe('filterDates', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('parses absolute ISO dates', () => {
|
||||
const parsed = parseDateFilterInput('2026-04-01')
|
||||
expect(parsed && format(parsed, 'yyyy-MM-dd')).toBe('2026-04-01')
|
||||
})
|
||||
|
||||
it('parses numeric relative day phrases', () => {
|
||||
const reference = new Date('2026-04-07T12:00:00Z')
|
||||
const parsed = parseDateFilterInput('10 days ago', reference)
|
||||
expect(parsed && format(parsed, 'yyyy-MM-dd')).toBe('2026-03-28')
|
||||
})
|
||||
|
||||
it('parses word-based relative week phrases', () => {
|
||||
const reference = new Date('2026-04-07T12:00:00Z')
|
||||
const parsed = parseDateFilterInput('one week ago', reference)
|
||||
expect(parsed && format(parsed, 'yyyy-MM-dd')).toBe('2026-03-31')
|
||||
})
|
||||
|
||||
it('returns null for unsupported date phrases', () => {
|
||||
expect(parseDateFilterInput('eventually')).toBeNull()
|
||||
})
|
||||
|
||||
it('converts parsed filter values into timestamps', () => {
|
||||
const reference = new Date('2026-04-07T12:00:00Z')
|
||||
const timestamp = toDateFilterTimestamp('yesterday', reference)
|
||||
expect(timestamp).not.toBeNull()
|
||||
expect(format(new Date(timestamp!), 'yyyy-MM-dd')).toBe('2026-04-06')
|
||||
})
|
||||
})
|
||||
104
src/utils/filterDates.ts
Normal file
104
src/utils/filterDates.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
addDays,
|
||||
addMonths,
|
||||
addWeeks,
|
||||
addYears,
|
||||
isValid,
|
||||
parseISO,
|
||||
startOfDay,
|
||||
subDays,
|
||||
subMonths,
|
||||
subWeeks,
|
||||
subYears,
|
||||
} from 'date-fns'
|
||||
|
||||
type RelativeUnit = 'day' | 'week' | 'month' | 'year'
|
||||
|
||||
const NUMBER_WORDS: Record<string, number> = {
|
||||
a: 1,
|
||||
an: 1,
|
||||
one: 1,
|
||||
two: 2,
|
||||
three: 3,
|
||||
four: 4,
|
||||
five: 5,
|
||||
six: 6,
|
||||
seven: 7,
|
||||
eight: 8,
|
||||
nine: 9,
|
||||
ten: 10,
|
||||
eleven: 11,
|
||||
twelve: 12,
|
||||
}
|
||||
|
||||
function parseRelativeAmount(token: string): number | null {
|
||||
if (/^\d+$/.test(token)) return Number(token)
|
||||
return NUMBER_WORDS[token] ?? null
|
||||
}
|
||||
|
||||
function normalizeRelativeUnit(token: string): RelativeUnit | null {
|
||||
const unit = token.toLowerCase().replace(/s$/, '')
|
||||
if (unit === 'day' || unit === 'week' || unit === 'month' || unit === 'year') return unit
|
||||
return null
|
||||
}
|
||||
|
||||
function shiftRelativeDate(reference: Date, unit: RelativeUnit, amount: number, future: boolean): Date {
|
||||
if (unit === 'day') return future ? addDays(reference, amount) : subDays(reference, amount)
|
||||
if (unit === 'week') return future ? addWeeks(reference, amount) : subWeeks(reference, amount)
|
||||
if (unit === 'month') return future ? addMonths(reference, amount) : subMonths(reference, amount)
|
||||
return future ? addYears(reference, amount) : subYears(reference, amount)
|
||||
}
|
||||
|
||||
function parseRelativeDateInput(value: string, reference: Date): Date | null {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (!normalized) return null
|
||||
|
||||
const base = startOfDay(reference)
|
||||
if (normalized === 'today') return base
|
||||
if (normalized === 'yesterday') return subDays(base, 1)
|
||||
if (normalized === 'tomorrow') return addDays(base, 1)
|
||||
|
||||
const tokens = normalized.split(/\s+/)
|
||||
let future = false
|
||||
let amountToken = ''
|
||||
let unitToken = ''
|
||||
|
||||
if (tokens.length === 3 && tokens[0] === 'in') {
|
||||
future = true
|
||||
amountToken = tokens[1]
|
||||
unitToken = tokens[2]
|
||||
} else if (tokens.length === 3 && tokens[2] === 'ago') {
|
||||
amountToken = tokens[0]
|
||||
unitToken = tokens[1]
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
const amount = parseRelativeAmount(amountToken)
|
||||
const unit = normalizeRelativeUnit(unitToken)
|
||||
if (amount == null || unit == null) return null
|
||||
|
||||
return shiftRelativeDate(base, unit, amount, future)
|
||||
}
|
||||
|
||||
export function parseDateFilterInput(value: string, reference = new Date()): Date | null {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
const relative = parseRelativeDateInput(trimmed, reference)
|
||||
if (relative) return relative
|
||||
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
||||
const parsed = parseISO(trimmed)
|
||||
return isValid(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
const timestamp = Date.parse(trimmed)
|
||||
if (Number.isNaN(timestamp)) return null
|
||||
return new Date(timestamp)
|
||||
}
|
||||
|
||||
export function toDateFilterTimestamp(value: string, reference = new Date()): number | null {
|
||||
const parsed = parseDateFilterInput(value, reference)
|
||||
return parsed ? parsed.getTime() : null
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest'
|
||||
import { evaluateView } from './viewFilters'
|
||||
import type { VaultEntry, ViewDefinition } from '../types'
|
||||
|
||||
@@ -19,6 +19,10 @@ function makeEntry(overrides: Partial<VaultEntry>): VaultEntry {
|
||||
}
|
||||
|
||||
describe('evaluateView', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('filters by type equals', () => {
|
||||
const view: ViewDefinition = {
|
||||
name: 'Projects', icon: null, color: null, sort: null,
|
||||
@@ -244,6 +248,22 @@ describe('evaluateView', () => {
|
||||
expect(result.map((e) => e.title)).toEqual(['Match'])
|
||||
})
|
||||
|
||||
it('before/after accept natural-language relative date phrases', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-04-07T12:00:00Z'))
|
||||
|
||||
const view: ViewDefinition = {
|
||||
name: 'Recent', icon: null, color: null, sort: null,
|
||||
filters: { all: [{ field: 'Date', op: 'after', value: '10 days ago' }] },
|
||||
}
|
||||
const entries = [
|
||||
makeEntry({ title: 'Older', properties: { Date: '2026-03-20' } }),
|
||||
makeEntry({ title: 'Recent', properties: { Date: '2026-03-30' } }),
|
||||
]
|
||||
const result = evaluateView(view, entries)
|
||||
expect(result.map((e) => e.title)).toEqual(['Recent'])
|
||||
})
|
||||
|
||||
it('body contains filters on snippet text (case-insensitive)', () => {
|
||||
const view: ViewDefinition = {
|
||||
name: 'Body search', icon: null, color: null, sort: null,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { VaultEntry, ViewDefinition, FilterGroup, FilterNode, FilterCondition } from '../types'
|
||||
import { toDateFilterTimestamp } from './filterDates'
|
||||
|
||||
/** Evaluate a view's filters against a list of entries, returning only matches. */
|
||||
export function evaluateView(definition: ViewDefinition, entries: VaultEntry[]): VaultEntry[] {
|
||||
@@ -169,12 +170,11 @@ function evaluateCondition(cond: FilterCondition, entry: VaultEntry): boolean {
|
||||
if (typeof resolved.scalar === 'number') {
|
||||
tsMs = resolved.scalar * 1000 // Unix timestamp (seconds) → milliseconds
|
||||
} else if (typeof resolved.scalar === 'string') {
|
||||
const parsed = Date.parse(resolved.scalar)
|
||||
tsMs = isNaN(parsed) ? null : parsed
|
||||
tsMs = toDateFilterTimestamp(resolved.scalar)
|
||||
}
|
||||
if (tsMs == null) return false
|
||||
const target = Date.parse(condVal)
|
||||
if (isNaN(target)) return false
|
||||
const target = toDateFilterTimestamp(condVal)
|
||||
if (target == null) return false
|
||||
return op === 'before' ? tsMs < target : tsMs > target
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user