From 037fd39c95790c764915bf163eea8c34e457d65b Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 7 Apr 2026 23:02:55 +0200 Subject: [PATCH] feat: support relative date view filters --- src-tauri/src/vault/views.rs | 116 +++++++++++++++++++++++++- src/components/FilterBuilder.test.tsx | 25 +++++- src/components/FilterBuilder.tsx | 56 ++++++++----- src/utils/filterDates.test.ts | 37 ++++++++ src/utils/filterDates.ts | 104 +++++++++++++++++++++++ src/utils/viewFilters.test.ts | 22 ++++- src/utils/viewFilters.ts | 8 +- 7 files changed, 335 insertions(+), 33 deletions(-) create mode 100644 src/utils/filterDates.test.ts create mode 100644 src/utils/filterDates.ts diff --git a/src-tauri/src/vault/views.rs b/src-tauri/src/vault/views.rs index 5dd048f0..ea8b7b1f 100644 --- a/src-tauri/src/vault/views.rs +++ b/src-tauri/src/vault/views.rs @@ -1,3 +1,4 @@ +use chrono::{DateTime, Duration, Months, NaiveDate, NaiveDateTime, Utc}; use regex::RegexBuilder; use serde::de::{self, MapAccess, Visitor}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; @@ -313,6 +314,82 @@ fn build_regex(pattern: &str) -> Option { .ok() } +fn parse_relative_amount(token: &str) -> Option { + 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::().ok(), + } +} + +fn parse_relative_date_filter(value: &str, reference: DateTime) -> Option> { + 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) -> Option { + 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 { matches!( 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::IsNotEmpty => field_value.as_deref().is_some_and(|s| !s.is_empty()), 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, }, 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, }, } @@ -558,6 +647,7 @@ fn yaml_value_to_string_vec(v: &serde_yaml::Value) -> Option> { #[cfg(test)] mod tests { use super::*; + use chrono::TimeZone; use std::collections::HashMap; fn make_entry(overrides: impl FnOnce(&mut VaultEntry)) -> VaultEntry { @@ -720,6 +810,28 @@ filters: 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] fn test_evaluate_nested_and_or() { let yaml = r#" diff --git a/src/components/FilterBuilder.test.tsx b/src/components/FilterBuilder.test.tsx index e73315ec..c58bcd25 100644 --- a/src/components/FilterBuilder.test.tsx +++ b/src/components/FilterBuilder.test.tsx @@ -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( void }) { - const parsed = value ? parseISO(value) : undefined - const selected = parsed && !isNaN(parsed.getTime()) ? parsed : undefined + const selected = value ? parseDateFilterInput(value) ?? undefined : undefined return ( - - - - - - onChange(day ? format(day, 'yyyy-MM-dd') : '')} - /> - - +
+ onChange(e.target.value)} + data-testid="date-value-input" + /> + + + + + + onChange(day ? format(day, 'yyyy-MM-dd') : '')} + /> + + +
) } diff --git a/src/utils/filterDates.test.ts b/src/utils/filterDates.test.ts new file mode 100644 index 00000000..000df304 --- /dev/null +++ b/src/utils/filterDates.test.ts @@ -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') + }) +}) diff --git a/src/utils/filterDates.ts b/src/utils/filterDates.ts new file mode 100644 index 00000000..31dd3fca --- /dev/null +++ b/src/utils/filterDates.ts @@ -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 = { + 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 +} diff --git a/src/utils/viewFilters.test.ts b/src/utils/viewFilters.test.ts index 24cea497..051a3a17 100644 --- a/src/utils/viewFilters.test.ts +++ b/src/utils/viewFilters.test.ts @@ -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 { } 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, diff --git a/src/utils/viewFilters.ts b/src/utils/viewFilters.ts index ec577ac1..85f0d8fe 100644 --- a/src/utils/viewFilters.ts +++ b/src/utils/viewFilters.ts @@ -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 }