feat: support relative date view filters

This commit is contained in:
Test
2026-04-07 23:02:55 +02:00
parent c19cefe989
commit 58dfb039ca
7 changed files with 335 additions and 33 deletions

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 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,

View File

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