Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6390,3 +6390,51 @@ chance to run and it is cheap. Reconciliation for this cycle: 41 commits, 40
mapped, 1 correctly absent, run by bento-team-slides.

Claude-Session: https://claude.ai/code/session_01Jcfdy8A69nonyATtm8vRy8


## 2026-09-10 — spaces view filters: a FLAT condition list, and unknown operators show MORE

**Decision.** `bento/spaces` `ViewFilter` gains two keys and no more: `where`, a
FLAT array of `{key, op, v?}` clauses, and `any`, a boolean that ORs them
instead of ANDing them. Eleven operators — `eq` `ne` `gt` `gte` `lt` `lte`
`contains` `notContains` `empty` `notEmpty` `in` — plus five relative date
windows for `in` (`today` `week` `month` `past` `future`). The engine is
`spaces/src/query.ts`; `fields.ts` calls into it and is otherwise unchanged.

**Why flat, and not a tree.** A nested group needs a UI that can show, build and
unbuild a tree, and the filter popover is a bottom sheet on a phone. "Due this
week AND not tagged draft" and "urgent OR overdue" are the shapes people
actually ask for and both are flat. Nesting stays available later as another
additive key, where widening a flat list into a tree afterwards would not be.

**Why `any` reaches only `where`.** The result is `open AND is AND (where,
combined by all-or-any)`. Letting `any` reach `is` or `open` would change what a
file already on somebody's disk means, which no key may ever do.

**Unknown operators show MORE rows, never fewer, and say so.** An operator this
build cannot evaluate is reported by `unknownFilterOps` (the sibling of
`unknownFilterKeys`) and treated as no constraint under AND — and as PASSING
under `any`, because skipping a clause in an OR leaves fewer ways through and
would hide rows for a rule nobody can read. Both directions are the same rule:
a superset with a banner over it, never a silently wrong set. This follows the
precedents already in `fields.ts` — `isOpenPhase` counts an unknown status as
open, an empty `is` list is no constraint. An OLDER build meeting `where` does
the same one level up: unknown key, superset, existing banner.

**Dates never construct a Date from a string.** A `date` field holds
`YYYY-MM-DD`, whose string order is its chronological order in every timezone,
so comparison is string comparison. Windows are built from journal.ts's
`todayISO`/`stepDay` — calendar arithmetic in the reader's own zone.
`new Date('2026-01-01')` is UTC midnight by spec and therefore the previous day
for half the world; it appears nowhere in query.ts. The week START is read from
`Intl.Locale.weekInfo` (Monday fallback) and is VIEWER-scoped, never stored:
the same file answers "this week" as Mon–Sun in Berlin and Sun–Sat in Chicago,
the same rule the app already follows for language and date formatting.

**No `eval`, no `new Function`** — a filter comes out of a mailed file exactly
like block html, and calc.ts's argument carries over unchanged. Fixed operator
table, typed values, returns a boolean.

Coverage: 69 behavioural assertions in `scripts/test-spaces-model.ts` asserting
on ROWS, including a compatibility block proving every pre-`where` filter shape
still selects exactly its old rows; 13 sabotages, all caught.
45 changes: 44 additions & 1 deletion docs/spaces-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ unique ids the first time.
| `pagelink` | `page` | a card linking to another page |
| `link` | `url`, `title`, `desc`, `site`, `icon`, `image`, `html` | a card linking OUT of the space — see **Link cards** |
| `prop` | `key`, `value`, `html` | one field value — see **The issue tracker** |
| `view` | `layout`, `groupBy`, `html` | a board or list of this space's issues |
| `view` | `layout`, `groupBy`, `sort`, `source`, `filter`, `html` | a board or list of this space's issues |

`type` is a **string**, not a closed set: an unknown type survives a round trip
and renders its `html` as a fallback. Properties are **flat on the block** —
Expand Down Expand Up @@ -266,6 +266,49 @@ list**: `{ "type": "view", "layout": "board", "groupBy": "status",
"html": "Issues by status" }`. Put it on a page of its own — a page carrying a
view is laid out wide.

### Narrowing a view

`filter` is optional and every key inside it is too. **Absent means everything**,
and so does an absent key — a view with no `filter` shows every row, forever.
Write no `filter` key at all rather than an empty object.

```json
{ "type": "view", "layout": "table", "filter": {
"open": true,
"is": { "labels": ["ui"] },
"where": [
{ "key": "due", "op": "in", "v": "past" },
{ "key": ":title", "op": "contains", "v": "onboarding" }
]
} }
```

- `open` — only rows whose status phase is neither `done` nor `cancelled`.
- `is` — `{ fieldKey: [values] }`, membership; a row passes a key if it holds
any of the listed values. **An empty list is no constraint**, not "nothing
passes".
- `where` — a flat list of conditions, ANDed. Set `"any": true` alongside it to
OR them instead; `any` reaches `where` only, and `open`/`is` always AND.
- Each condition is `{ key, op, v? }`. `key` is a field key, or `":title"` for
the page title.

| `op` | means | `v` |
|---|---|---|
| `eq` / `ne` | exact match on the STORED value; membership for a list-valued field | string or number |
| `gt` `gte` `lt` `lte` | numeric for a `number` field, otherwise text — and a `date` field's `YYYY-MM-DD` sorts chronologically as text | string or number |
| `contains` / `notContains` | case-insensitive substring of the READABLE value (a select's label, a labels list joined) | string |
| `empty` / `notEmpty` | the value is unset — absent, `""` or `[]` | — omit `v` |
| `in` | a date within a window resolved against the READER's today | `"today"` `"week"` `"month"` `"past"` `"future"` |

Two conditions on one number make a range. `"past"` on a due date is "overdue".
Relative windows are stored as the WORD and resolved when the view is drawn, in
the reader's own timezone and week — a stored `{"op":"lt","v":"2026-09-10"}`
would be right on the day you wrote it and wrong every day after.

An operator a build cannot evaluate is **not applied and reported**, so the view
shows a superset with a banner over it rather than the wrong rows. That is the
same trade the format makes everywhere: never silently narrow.

**Not in this format, deliberately**: teams, per-user permissions,
notifications, automation. The file is the team boundary and the capability.

Expand Down
Loading
Loading