feat: support relative date view filters

This commit is contained in:
lucaronin
2026-04-07 23:02:55 +02:00
parent b0b6a4f4cd
commit 037fd39c95
7 changed files with 335 additions and 33 deletions

View File

@@ -1,3 +1,4 @@
use chrono::{DateTime, Duration, Months, NaiveDate, NaiveDateTime, Utc};
use regex::RegexBuilder; use regex::RegexBuilder;
use serde::de::{self, MapAccess, Visitor}; use serde::de::{self, MapAccess, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde::{Deserialize, Deserializer, Serialize, Serializer};
@@ -313,6 +314,82 @@ fn build_regex(pattern: &str) -> Option<regex::Regex> {
.ok() .ok()
} }
fn parse_relative_amount(token: &str) -> Option<u32> {
match token {
"a" | "an" | "one" => Some(1),
"two" => Some(2),
"three" => Some(3),
"four" => Some(4),
"five" => Some(5),
"six" => Some(6),
"seven" => Some(7),
"eight" => Some(8),
"nine" => Some(9),
"ten" => Some(10),
"eleven" => Some(11),
"twelve" => Some(12),
_ => token.parse::<u32>().ok(),
}
}
fn parse_relative_date_filter(value: &str, reference: DateTime<Utc>) -> Option<DateTime<Utc>> {
let normalized = value.trim().to_lowercase();
if normalized.is_empty() {
return None;
}
let base = reference.date_naive().and_hms_opt(0, 0, 0)?.and_utc();
match normalized.as_str() {
"today" => return Some(base),
"yesterday" => return Some(base - Duration::days(1)),
"tomorrow" => return Some(base + Duration::days(1)),
_ => {}
}
let tokens: Vec<&str> = normalized.split_whitespace().collect();
let (future, amount_token, unit_token) = match tokens.as_slice() {
["in", amount, unit] => (true, *amount, *unit),
[amount, unit, "ago"] => (false, *amount, *unit),
_ => return None,
};
let amount = parse_relative_amount(amount_token)?;
let unit = unit_token.strip_suffix('s').unwrap_or(unit_token);
match (future, unit) {
(true, "day") => Some(base + Duration::days(amount as i64)),
(false, "day") => Some(base - Duration::days(amount as i64)),
(true, "week") => Some(base + Duration::weeks(amount as i64)),
(false, "week") => Some(base - Duration::weeks(amount as i64)),
(true, "month") => base.checked_add_months(Months::new(amount)),
(false, "month") => base.checked_sub_months(Months::new(amount)),
(true, "year") => base.checked_add_months(Months::new(amount * 12)),
(false, "year") => base.checked_sub_months(Months::new(amount * 12)),
_ => None,
}
}
fn parse_date_filter_timestamp(value: &str, reference: DateTime<Utc>) -> Option<i64> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
if let Ok(date) = NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
return Some(date.and_hms_opt(0, 0, 0)?.and_utc().timestamp_millis());
}
if let Ok(datetime) = NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S") {
return Some(datetime.and_utc().timestamp_millis());
}
if let Ok(datetime) = DateTime::parse_from_rfc3339(trimmed) {
return Some(datetime.with_timezone(&Utc).timestamp_millis());
}
parse_relative_date_filter(trimmed, reference).map(|datetime| datetime.timestamp_millis())
}
fn supports_regex(op: &FilterOp) -> bool { fn supports_regex(op: &FilterOp) -> bool {
matches!( matches!(
op, op,
@@ -434,11 +511,23 @@ fn evaluate_condition(cond: &FilterCondition, entry: &VaultEntry) -> bool {
FilterOp::IsEmpty => field_value.as_deref().map_or(true, |s| s.is_empty()), FilterOp::IsEmpty => field_value.as_deref().map_or(true, |s| s.is_empty()),
FilterOp::IsNotEmpty => field_value.as_deref().is_some_and(|s| !s.is_empty()), FilterOp::IsNotEmpty => field_value.as_deref().is_some_and(|s| !s.is_empty()),
FilterOp::Before => match (&field_value, &cond_value) { FilterOp::Before => match (&field_value, &cond_value) {
(Some(f), Some(v)) => f < v, (Some(f), Some(v)) => match (
parse_date_filter_timestamp(f, Utc::now()),
parse_date_filter_timestamp(v, Utc::now()),
) {
(Some(field_ts), Some(target_ts)) => field_ts < target_ts,
_ => false,
},
_ => false, _ => false,
}, },
FilterOp::After => match (&field_value, &cond_value) { FilterOp::After => match (&field_value, &cond_value) {
(Some(f), Some(v)) => f > v, (Some(f), Some(v)) => match (
parse_date_filter_timestamp(f, Utc::now()),
parse_date_filter_timestamp(v, Utc::now()),
) {
(Some(field_ts), Some(target_ts)) => field_ts > target_ts,
_ => false,
},
_ => false, _ => false,
}, },
} }
@@ -558,6 +647,7 @@ fn yaml_value_to_string_vec(v: &serde_yaml::Value) -> Option<Vec<String>> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use chrono::TimeZone;
use std::collections::HashMap; use std::collections::HashMap;
fn make_entry(overrides: impl FnOnce(&mut VaultEntry)) -> VaultEntry { fn make_entry(overrides: impl FnOnce(&mut VaultEntry)) -> VaultEntry {
@@ -720,6 +810,28 @@ filters:
assert!(result.is_empty()); assert!(result.is_empty());
} }
#[test]
fn test_parse_relative_date_filter_days_ago() {
let reference = Utc.with_ymd_and_hms(2026, 4, 7, 12, 0, 0).unwrap();
let parsed = parse_date_filter_timestamp("10 days ago", reference).unwrap();
let expected = Utc
.with_ymd_and_hms(2026, 3, 28, 0, 0, 0)
.unwrap()
.timestamp_millis();
assert_eq!(parsed, expected);
}
#[test]
fn test_parse_relative_date_filter_one_week_ago() {
let reference = Utc.with_ymd_and_hms(2026, 4, 7, 12, 0, 0).unwrap();
let parsed = parse_date_filter_timestamp("one week ago", reference).unwrap();
let expected = Utc
.with_ymd_and_hms(2026, 3, 31, 0, 0, 0)
.unwrap()
.timestamp_millis();
assert_eq!(parsed, expected);
}
#[test] #[test]
fn test_evaluate_nested_and_or() { fn test_evaluate_nested_and_or() {
let yaml = r#" let yaml = r#"

View File

@@ -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 { render, screen, fireEvent } from '@testing-library/react'
import { FilterBuilder } from './FilterBuilder' import { FilterBuilder } from './FilterBuilder'
import type { FilterGroup } from '../types' import type { FilterGroup } from '../types'
@@ -10,6 +10,10 @@ describe('FilterBuilder value inputs', () => {
vi.clearAllMocks() vi.clearAllMocks()
}) })
afterEach(() => {
vi.useRealTimers()
})
function renderBuilder(group?: FilterGroup) { function renderBuilder(group?: FilterGroup) {
const defaultGroup: FilterGroup = { const defaultGroup: FilterGroup = {
all: [{ field: 'title', op: 'contains', value: '' }], all: [{ field: 'title', op: 'contains', value: '' }],
@@ -97,10 +101,10 @@ describe('FilterBuilder value inputs', () => {
all: [{ field: 'created', op: 'before', value: '2024-06-01' }], 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') const dateButton = screen.getByTestId('date-picker-trigger')
expect(dateButton).toBeInTheDocument() expect(dateButton).toBeInTheDocument()
expect(dateButton).toHaveTextContent('Jun 1, 2024') expect(dateButton).toHaveAttribute('title', 'Jun 1, 2024')
expect(screen.queryByDisplayValue('2024-06-01')).not.toBeInTheDocument()
}) })
it('renders date picker placeholder when no date is selected', () => { it('renders date picker placeholder when no date is selected', () => {
@@ -108,10 +112,23 @@ describe('FilterBuilder value inputs', () => {
all: [{ field: 'created', op: 'after', value: '' }], 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() 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', () => { it('shows body field in field dropdown separated from property fields', () => {
render( render(
<FilterBuilder <FilterBuilder

View File

@@ -1,5 +1,5 @@
import { Plus, X, CalendarBlank, WarningCircle } from '@phosphor-icons/react' 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 { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Calendar } from '@/components/ui/calendar' 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 { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { FilterCondition, FilterOp, FilterGroup, FilterNode } from '../types' import type { FilterCondition, FilterOp, FilterGroup, FilterNode } from '../types'
import { parseDateFilterInput } from '../utils/filterDates'
const OPERATORS: { value: FilterOp; label: string }[] = [ const OPERATORS: { value: FilterOp; label: string }[] = [
{ value: 'equals', label: 'equals' }, { value: 'equals', label: 'equals' },
@@ -116,28 +117,39 @@ function OperatorSelect({ value, onChange }: {
} }
function DateValueInput({ value, onChange }: { value: string; onChange: (v: string) => void }) { function DateValueInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const parsed = value ? parseISO(value) : undefined const selected = value ? parseDateFilterInput(value) ?? undefined : undefined
const selected = parsed && !isNaN(parsed.getTime()) ? parsed : undefined
return ( return (
<Popover> <div className="flex flex-1 min-w-0 items-center gap-1">
<PopoverTrigger asChild> <Input
<Button className="h-8 flex-1 min-w-0 text-sm"
variant="outline" placeholder='YYYY-MM-DD or "10 days ago"'
data-testid="date-picker-trigger" value={value}
className="h-8 flex-1 min-w-0 justify-start gap-2 px-2 text-sm font-normal" onChange={(e) => onChange(e.target.value)}
> data-testid="date-value-input"
<CalendarBlank size={14} className="shrink-0 text-muted-foreground" /> />
{selected ? format(selected, 'MMM d, yyyy') : <span className="text-muted-foreground">Pick a date</span>} <Popover>
</Button> <PopoverTrigger asChild>
</PopoverTrigger> <Button
<PopoverContent className="w-auto p-0" align="start"> type="button"
<Calendar variant="outline"
mode="single" size="sm"
selected={selected} data-testid="date-picker-trigger"
onSelect={(day) => onChange(day ? format(day, 'yyyy-MM-dd') : '')} className="h-8 w-8 shrink-0 px-0"
/> title={selected ? format(selected, 'MMM d, yyyy') : 'Pick a date'}
</PopoverContent> aria-label={selected ? `Open date picker (${format(selected, 'MMM d, yyyy')})` : 'Open date picker'}
</Popover> >
<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>
) )
} }

View 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
View 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
}

View File

@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest' import { afterEach, describe, it, expect, vi } from 'vitest'
import { evaluateView } from './viewFilters' import { evaluateView } from './viewFilters'
import type { VaultEntry, ViewDefinition } from '../types' import type { VaultEntry, ViewDefinition } from '../types'
@@ -19,6 +19,10 @@ function makeEntry(overrides: Partial<VaultEntry>): VaultEntry {
} }
describe('evaluateView', () => { describe('evaluateView', () => {
afterEach(() => {
vi.useRealTimers()
})
it('filters by type equals', () => { it('filters by type equals', () => {
const view: ViewDefinition = { const view: ViewDefinition = {
name: 'Projects', icon: null, color: null, sort: null, name: 'Projects', icon: null, color: null, sort: null,
@@ -244,6 +248,22 @@ describe('evaluateView', () => {
expect(result.map((e) => e.title)).toEqual(['Match']) 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)', () => { it('body contains filters on snippet text (case-insensitive)', () => {
const view: ViewDefinition = { const view: ViewDefinition = {
name: 'Body search', icon: null, color: null, sort: null, name: 'Body search', icon: null, color: null, sort: null,

View File

@@ -1,4 +1,5 @@
import type { VaultEntry, ViewDefinition, FilterGroup, FilterNode, FilterCondition } from '../types' 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. */ /** Evaluate a view's filters against a list of entries, returning only matches. */
export function evaluateView(definition: ViewDefinition, entries: VaultEntry[]): VaultEntry[] { export function evaluateView(definition: ViewDefinition, entries: VaultEntry[]): VaultEntry[] {
@@ -169,12 +170,11 @@ function evaluateCondition(cond: FilterCondition, entry: VaultEntry): boolean {
if (typeof resolved.scalar === 'number') { if (typeof resolved.scalar === 'number') {
tsMs = resolved.scalar * 1000 // Unix timestamp (seconds) → milliseconds tsMs = resolved.scalar * 1000 // Unix timestamp (seconds) → milliseconds
} else if (typeof resolved.scalar === 'string') { } else if (typeof resolved.scalar === 'string') {
const parsed = Date.parse(resolved.scalar) tsMs = toDateFilterTimestamp(resolved.scalar)
tsMs = isNaN(parsed) ? null : parsed
} }
if (tsMs == null) return false if (tsMs == null) return false
const target = Date.parse(condVal) const target = toDateFilterTimestamp(condVal)
if (isNaN(target)) return false if (target == null) return false
return op === 'before' ? tsMs < target : tsMs > target return op === 'before' ? tsMs < target : tsMs > target
} }