From d81c696742fb7dfd8c1644714d1c469dfe00564d Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 16 May 2026 18:34:41 +0200 Subject: [PATCH] fix: resolve relative dates in view filters --- src/utils/viewFilters.relativeDates.test.ts | 95 +++++++++++++++++++++ src/utils/viewFilters.ts | 66 ++++++++++++-- 2 files changed, 156 insertions(+), 5 deletions(-) create mode 100644 src/utils/viewFilters.relativeDates.test.ts diff --git a/src/utils/viewFilters.relativeDates.test.ts b/src/utils/viewFilters.relativeDates.test.ts new file mode 100644 index 00000000..0619ebf9 --- /dev/null +++ b/src/utils/viewFilters.relativeDates.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { VaultEntry, ViewDefinition } from '../types' +import { evaluateView } from './viewFilters' + +const BASE_ENTRY: VaultEntry = { + path: '/vault/test.md', + filename: 'test.md', + title: 'Test', + isA: null, + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + archived: false, + modifiedAt: 1, + createdAt: 1, + fileSize: 100, + snippet: '', + wordCount: 0, + relationships: {}, + icon: null, + color: null, + order: null, + sidebarLabel: null, + template: null, + sort: null, + view: null, + visible: null, + favorite: false, + favoriteIndex: null, + outgoingLinks: [], + properties: {}, + listPropertiesDisplay: [], +} + +function makeEntry(title: string, date: string): VaultEntry { + return { + ...BASE_ENTRY, + path: `/vault/${title.toLowerCase()}.md`, + filename: `${title.toLowerCase()}.md`, + title, + properties: { date }, + } +} + +function dateEqualsView(value: string): ViewDefinition { + return { + name: 'Relative date', + icon: null, + color: null, + sort: null, + filters: { all: [{ field: 'date', op: 'equals', value }] }, + } +} + +describe('relative date view filters', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('matches today against date frontmatter at query time', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-04-07T12:00:00Z')) + + const result = evaluateView(dateEqualsView('today'), [ + makeEntry('Yesterday', '2026-04-06'), + makeEntry('Today', '2026-04-07'), + makeEntry('Tomorrow', '2026-04-08'), + ]) + + expect(result.map((entry) => entry.title)).toEqual(['Today']) + }) + + it('matches future relative expressions against date frontmatter', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-04-07T12:00:00Z')) + + const result = evaluateView(dateEqualsView('in 1 week'), [ + makeEntry('Today', '2026-04-07'), + makeEntry('Next Week', '2026-04-14'), + makeEntry('Later', '2026-04-15'), + ]) + + expect(result.map((entry) => entry.title)).toEqual(['Next Week']) + }) + + it('keeps unsupported calendar tokens from matching arbitrary dates', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-04-07T12:00:00Z')) + + expect(evaluateView(dateEqualsView('this month'), [ + makeEntry('April Entry', '2026-04-15'), + ])).toEqual([]) + }) +}) diff --git a/src/utils/viewFilters.ts b/src/utils/viewFilters.ts index 595f9336..af32a3d9 100644 --- a/src/utils/viewFilters.ts +++ b/src/utils/viewFilters.ts @@ -174,6 +174,66 @@ function evaluateDateCondition(cond: FilterCondition, scalar: string | number | return cond.op === 'before' ? tsMs < target : tsMs > target } +type DateTimestamps = { + left: number + right: number +} + +function isSameLocalDay(timestamps: DateTimestamps): boolean { + const leftTimestamp = timestamps.left + const rightTimestamp = timestamps.right + const leftDate = new Date(leftTimestamp) + const rightDate = new Date(rightTimestamp) + return leftDate.getFullYear() === rightDate.getFullYear() + && leftDate.getMonth() === rightDate.getMonth() + && leftDate.getDate() === rightDate.getDate() +} + +type DateEqualityCondition = { + op: FilterCondition['op'] + scalar: FieldScalar + condVal: string +} + +function evaluateDateEqualityCondition(condition: DateEqualityCondition): boolean | null { + const { op, scalar, condVal } = condition + if (op !== 'equals' && op !== 'not_equals') return null + + const tsMs = fieldTimestamp(scalar) + const target = toDateFilterTimestamp(condVal) + if (tsMs == null || target == null) return null + + const matched = isSameLocalDay({ left: tsMs, right: target }) + return op === 'equals' ? matched : !matched +} + +type ScalarDateCondition = { + cond: FilterCondition + scalar: FieldScalar + condVal: string +} + +function evaluateScalarDateCondition(condition: ScalarDateCondition): boolean | null { + const { cond, scalar, condVal } = condition + if (cond.op === 'before' || cond.op === 'after') { + return evaluateDateCondition(cond, scalar, condVal) + } + + return evaluateDateEqualityCondition({ op: cond.op, scalar, condVal }) +} + +type ScalarCondition = ScalarDateCondition & { + regex: RegExp | null +} + +function evaluateScalarCondition(condition: ScalarCondition): boolean { + const dateResult = evaluateScalarDateCondition(condition) + if (dateResult !== null) return dateResult + + const { cond, scalar, condVal, regex } = condition + return evaluateTextCondition(cond, toString(scalar), condVal, regex) +} + function evaluateCondition(cond: FilterCondition, entry: VaultEntry): boolean { const resolved = resolveField(entry, cond.field) const emptyResult = evaluateEmptyCondition(cond.op, resolved) @@ -187,9 +247,5 @@ function evaluateCondition(cond: FilterCondition, entry: VaultEntry): boolean { return evaluateArrayCondition(cond, resolved, condVal, regex) } - if (cond.op === 'before' || cond.op === 'after') { - return evaluateDateCondition(cond, resolved.value, condVal) - } - - return evaluateTextCondition(cond, toString(resolved.value), condVal, regex) + return evaluateScalarCondition({ cond, scalar: resolved.value, condVal, regex }) }