From 00baf45a2f2ccdca965cf512034aba5505efcae2 Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Sun, 22 Feb 2026 02:08:25 -0800 Subject: [PATCH] fix(web): handle array-style date values in object view filters YAML-parsed date_between/between filters store range values as a two-element array under `value`, but the filter system expected separate value/valueTo fields. Add normalizeFilterGroup at the YAML read boundary and defensive array handling in the evaluator and SQL builder so both formats work correctly. Co-authored-by: Cursor --- apps/web/lib/object-filters.test.ts | 227 ++++++++++++++++++++++++++++ apps/web/lib/object-filters.ts | 57 ++++++- apps/web/lib/workspace.ts | 7 +- skills/dench/SKILL.md | 13 ++ 4 files changed, 294 insertions(+), 10 deletions(-) create mode 100644 apps/web/lib/object-filters.test.ts diff --git a/apps/web/lib/object-filters.test.ts b/apps/web/lib/object-filters.test.ts new file mode 100644 index 00000000000..70b08a5eb8b --- /dev/null +++ b/apps/web/lib/object-filters.test.ts @@ -0,0 +1,227 @@ +import { describe, it, expect } from "vitest"; +import { + normalizeFilterRule, + normalizeFilterGroup, + matchesFilter, + buildWhereClause, + type FilterRule, + type FilterGroup, + type FieldMeta, +} from "./object-filters"; + +// ─── normalizeFilterRule ─── + +describe("normalizeFilterRule", () => { + it("splits array value into value/valueTo for date_between", () => { + const rule: FilterRule = { + id: "r1", + field: "Due Date", + operator: "date_between", + value: ["2026-03-01", "2026-03-31"], + }; + const result = normalizeFilterRule(rule); + expect(result.value).toBe("2026-03-01"); + expect(result.valueTo).toBe("2026-03-31"); + }); + + it("splits array value into value/valueTo for numeric between", () => { + const rule: FilterRule = { + id: "r2", + field: "Score", + operator: "between", + value: ["10", "50"] as unknown as string[], + }; + const result = normalizeFilterRule(rule); + expect(result.value).toBe("10"); + expect(result.valueTo).toBe("50"); + }); + + it("does not touch date_between when valueTo already set", () => { + const rule: FilterRule = { + id: "r3", + field: "Due Date", + operator: "date_between", + value: "2026-03-01", + valueTo: "2026-03-31", + }; + const result = normalizeFilterRule(rule); + expect(result.value).toBe("2026-03-01"); + expect(result.valueTo).toBe("2026-03-31"); + }); + + it("does not touch non-range operators", () => { + const rule: FilterRule = { + id: "r4", + field: "Status", + operator: "is_any_of", + value: ["In Progress", "Done"], + }; + const result = normalizeFilterRule(rule); + expect(result.value).toEqual(["In Progress", "Done"]); + expect(result.valueTo).toBeUndefined(); + }); +}); + +// ─── normalizeFilterGroup ─── + +describe("normalizeFilterGroup", () => { + it("recursively normalizes nested groups", () => { + const group: FilterGroup = { + id: "root", + conjunction: "and", + rules: [ + { + id: "f1", + field: "Due Date", + operator: "date_between", + value: ["2026-03-01", "2026-03-31"], + } as FilterRule, + { + id: "nested", + conjunction: "or", + rules: [ + { + id: "f2", + field: "Score", + operator: "between", + value: ["1", "100"] as unknown as string[], + } as FilterRule, + ], + }, + ], + }; + const result = normalizeFilterGroup(group); + + const r1 = result.rules[0] as FilterRule; + expect(r1.value).toBe("2026-03-01"); + expect(r1.valueTo).toBe("2026-03-31"); + + const nested = result.rules[1] as FilterGroup; + const r2 = nested.rules[0] as FilterRule; + expect(r2.value).toBe("1"); + expect(r2.valueTo).toBe("100"); + }); +}); + +// ─── matchesFilter with array-style date_between ─── + +describe("matchesFilter with array-style date_between", () => { + const entries = [ + { "Due Date": "2026-03-10" }, + { "Due Date": "2026-02-28" }, + { "Due Date": "2026-04-01" }, + { "Due Date": "2026-03-01" }, + { "Due Date": "2026-03-31" }, + ]; + + it("correctly filters with normalized value/valueTo", () => { + const filters: FilterGroup = { + id: "root", + conjunction: "and", + rules: [ + { + id: "f1", + field: "Due Date", + operator: "date_between", + value: "2026-03-01", + valueTo: "2026-03-31", + }, + ], + }; + const result = matchesFilter(entries, filters); + expect(result).toEqual([ + { "Due Date": "2026-03-10" }, + { "Due Date": "2026-03-01" }, + { "Due Date": "2026-03-31" }, + ]); + }); + + it("correctly filters with array-style value (defensive fallback)", () => { + const filters: FilterGroup = { + id: "root", + conjunction: "and", + rules: [ + { + id: "f1", + field: "Due Date", + operator: "date_between", + value: ["2026-03-01", "2026-03-31"], + } as FilterRule, + ], + }; + const result = matchesFilter(entries, filters); + expect(result).toEqual([ + { "Due Date": "2026-03-10" }, + { "Due Date": "2026-03-01" }, + { "Due Date": "2026-03-31" }, + ]); + }); +}); + +// ─── buildWhereClause with array-style date_between ─── + +describe("buildWhereClause with array-style date_between", () => { + const fields: FieldMeta[] = [{ name: "Due Date", type: "date" }]; + + it("builds correct SQL with value/valueTo", () => { + const filters: FilterGroup = { + id: "root", + conjunction: "and", + rules: [ + { + id: "f1", + field: "Due Date", + operator: "date_between", + value: "2026-03-01", + valueTo: "2026-03-31", + }, + ], + }; + const sql = buildWhereClause(filters, fields); + expect(sql).toBe( + `((CAST("Due Date" AS DATE) BETWEEN '2026-03-01' AND '2026-03-31'))`, + ); + }); + + it("builds correct SQL with array-style value (defensive fallback)", () => { + const filters: FilterGroup = { + id: "root", + conjunction: "and", + rules: [ + { + id: "f1", + field: "Due Date", + operator: "date_between", + value: ["2026-03-01", "2026-03-31"], + } as FilterRule, + ], + }; + const sql = buildWhereClause(filters, fields); + expect(sql).toBe( + `((CAST("Due Date" AS DATE) BETWEEN '2026-03-01' AND '2026-03-31'))`, + ); + }); +}); + +// ─── buildWhereClause with array-style numeric between ─── + +describe("buildWhereClause with array-style numeric between", () => { + const fields: FieldMeta[] = [{ name: "Score", type: "number" }]; + + it("builds correct SQL with array-style value", () => { + const filters: FilterGroup = { + id: "root", + conjunction: "and", + rules: [ + { + id: "f1", + field: "Score", + operator: "between", + value: ["10", "50"] as unknown as string[], + } as FilterRule, + ], + }; + const sql = buildWhereClause(filters, fields); + expect(sql).toBe(`((CAST("Score" AS DOUBLE) BETWEEN 10 AND 50))`); + }); +}); diff --git a/apps/web/lib/object-filters.ts b/apps/web/lib/object-filters.ts index d5ba6dfa9af..09c858861da 100644 --- a/apps/web/lib/object-filters.ts +++ b/apps/web/lib/object-filters.ts @@ -209,6 +209,43 @@ export function isFilterGroup( return "conjunction" in rule && "rules" in rule; } +// --------------------------------------------------------------------------- +// YAML normalization — convert array-style range values to value/valueTo +// --------------------------------------------------------------------------- + +const RANGE_OPS = new Set(["date_between", "between"]); + +/** + * Normalize a single filter rule so that array-style range values + * (e.g. `value: ["2026-03-01", "2026-03-31"]`) are split into + * separate `value` and `valueTo` fields. + */ +export function normalizeFilterRule(rule: FilterRule): FilterRule { + if ( + RANGE_OPS.has(rule.operator) && + Array.isArray(rule.value) && + rule.value.length >= 2 && + rule.valueTo == null + ) { + return { ...rule, value: rule.value[0], valueTo: rule.value[1] }; + } + return rule; +} + +/** + * Recursively normalize all filter rules in a group. + */ +export function normalizeFilterGroup(group: FilterGroup): FilterGroup { + return { + ...group, + rules: group.rules.map((r) => + isFilterGroup(r) + ? normalizeFilterGroup(r) + : normalizeFilterRule(r), + ), + }; +} + // --------------------------------------------------------------------------- // Client-side evaluator // --------------------------------------------------------------------------- @@ -347,8 +384,9 @@ function evaluateRule( case "lte": return n <= v!; case "between": { - const lo = coerceNumber(rule.value); - const hi = coerceNumber(rule.valueTo); + const arr = Array.isArray(rule.value) ? rule.value : null; + const lo = coerceNumber(arr ? arr[0] : rule.value); + const hi = coerceNumber(arr ? arr[1] : rule.valueTo); if (lo == null || hi == null) {return false;} return n >= lo && n <= hi; } @@ -391,8 +429,9 @@ function evaluateRule( case "after": return dateStr > target; case "date_between": { - const from = coerceString(rule.value); - const to = coerceString(rule.valueTo); + const arr = Array.isArray(rule.value) ? rule.value : null; + const from = coerceString(arr ? arr[0] : rule.value); + const to = coerceString(arr ? arr[1] : rule.valueTo); return dateStr >= from && dateStr <= to; } } @@ -538,8 +577,9 @@ function buildRuleSQL(rule: FilterRule, fields: FieldMeta[]): string | null { if (op === "lt" && numVal != null) {return `(CAST(${col} AS DOUBLE) < ${numVal})`;} if (op === "lte" && numVal != null) {return `(CAST(${col} AS DOUBLE) <= ${numVal})`;} if (op === "between") { - const lo = coerceNumber(rule.value); - const hi = coerceNumber(rule.valueTo); + const arr = Array.isArray(rule.value) ? rule.value : null; + const lo = coerceNumber(arr ? arr[0] : rule.value); + const hi = coerceNumber(arr ? arr[1] : rule.valueTo); if (lo != null && hi != null) {return `(CAST(${col} AS DOUBLE) BETWEEN ${lo} AND ${hi})`;} } @@ -557,8 +597,9 @@ function buildRuleSQL(rule: FilterRule, fields: FieldMeta[]): string | null { return `(CAST(${col} AS DATE) > '${sqlEscape(d)}')`; } if (op === "date_between") { - const from = coerceString(rule.value); - const to = coerceString(rule.valueTo); + const arr = Array.isArray(rule.value) ? rule.value : null; + const from = coerceString(arr ? arr[0] : rule.value); + const to = coerceString(arr ? arr[1] : rule.valueTo); return `(CAST(${col} AS DATE) BETWEEN '${sqlEscape(from)}' AND '${sqlEscape(to)}')`; } if (op === "relative_past") { diff --git a/apps/web/lib/workspace.ts b/apps/web/lib/workspace.ts index 37601abcf23..2f1d19eca19 100644 --- a/apps/web/lib/workspace.ts +++ b/apps/web/lib/workspace.ts @@ -4,7 +4,7 @@ import { promisify } from "node:util"; import { join, resolve, normalize, relative } from "node:path"; import { homedir } from "node:os"; import YAML from "yaml"; -import type { SavedView } from "./object-filters"; +import { normalizeFilterGroup, type SavedView } from "./object-filters"; const execAsync = promisify(exec); @@ -869,7 +869,10 @@ export function getObjectViews(objectName: string): { if (!config) {return { views: [], activeView: undefined };} return { - views: config.views ?? [], + views: (config.views ?? []).map((v) => ({ + ...v, + filters: v.filters ? normalizeFilterGroup(v.filters) : undefined, + })), activeView: config.active_view, }; } diff --git a/skills/dench/SKILL.md b/skills/dench/SKILL.md index 39760aec4ef..cfb96641f31 100644 --- a/skills/dench/SKILL.md +++ b/skills/dench/SKILL.md @@ -117,6 +117,19 @@ views: active_view: "Active deals" ``` +**Date format**: All date filter values MUST use ISO 8601 `YYYY-MM-DD` strings (e.g. `"2026-03-01"`). The special value `today` is also supported for `on`, `before`, and `after` operators. + +**Date range filter** (`date_between`): + +```yaml +- id: f1 + field: Due Date + operator: date_between + value: + - "2026-03-01" + - "2026-03-31" +``` + **Relative date filters** (e.g. "in the last 7 days"): ```yaml