Skip to content

spaces: view filters that can ask a real question - #436

Open
nyblnet wants to merge 1 commit into
mainfrom
spaces-query
Open

spaces: view filters that can ask a real question#436
nyblnet wants to merge 1 commit into
mainfrom
spaces-query

Conversation

@nyblnet

@nyblnet nyblnet commented Sep 10, 2026

Copy link
Copy Markdown
Owner

The gap

passesFilter supported exactly two things: an open phase flag and is: {key: [values]} — membership. So "books published after 2020", "tasks due this week", "pages NOT tagged draft", "title contains X" and "has no due date" were all unexpressible, on five view layouts that exist to hold exactly that material.

What this adds

One new file, spaces/src/query.ts. ViewFilter gains two keys and no more:

  • where — a FLAT array of {key, op, v?} conditions, ANDed.
  • any — a boolean that ORs them instead.

Eleven operators, argued per field type rather than collected:

op for notes
eq / ne every type exact match on the STORED value; membership for a list-valued field, mirroring what is already does for arrays
gt gte lt lte number, date numeric for number, otherwise text — and a date's YYYY-MM-DD sorts chronologically as text, so before/after/on are these under other names
contains / notContains text-shaped case-insensitive, over the READABLE value (a select's label, a labels list joined)
empty / notEmpty every type the question is could never ask — an unset value is the absence of a value, not one of its values
in date a relative window: today week month past future

Two conditions on one number AND into a range. past on a due date is "overdue". A condition can also name :title, which is not a prop block and no field key could reach.

Why flat, 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.

Format additivity — both directions

  • Every filter written before this behaves exactly as it does today. The compatibility block in the rig walks every shape a pre-where file could carry (open, is, list-valued is, both keys together, an empty value list) over the same four rows and asserts the exact id string each selects.
  • A filter returned to "no constraint" DELETES the key. Removing the last condition drops where and any; clearing drops all four. Verified in the browser: after adding a condition through the UI and removing it, the view block's keys are back to id, type, html, layout, groupBy.
  • An older build meeting where/any sees unknown keys, shows a SUPERSET, and raises the banner it already had for a newer sort. That is the existing trade, unchanged.
  • A newer operator meeting this build does the same one level down. unknownFilterOps (the sibling of unknownFilterKeys) reports it, render.ts folds it into the same banner, and the clause is 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: show more, never less, and say so. This follows the precedents already in fields.ts (isOpenPhase counts an unknown status as open; an empty is list is no constraint).

Dates and timezones

new Date('2026-01-01') appears nowhere in query.ts and must not. A date field holds what <input type="date"> holds — YYYY-MM-DD, no zone — so comparison is string comparison, which is chronological for that shape everywhere. The windows are built from journal.ts's todayISO / stepDay, which are calendar arithmetic in the reader's own zone.

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.

Untrusted input

where may be a string; a clause may be null, a number, or missing its op; a key may be __proto__ or toString; v may be an object. All of it is shape-checked and none of it throws or empties a board — asserted, not asserted-about-the-source. Operator and window lookups go through Set.has, so a word out of a mailed file can never reach a prototype. No eval, no new Function — a fixed operator table that can only return a boolean, for the reason calc.ts's header argues at length.

The UI

openViewFilter keeps the value toggles first and unchanged, and adds a Conditions section under them: each condition listed in words (one tap removes it), Add condition, and — only when there are two or more — one Match all / Match any switch. Add condition opens a small form: Field, Condition, Value, where the operator list follows the field type and the value control follows the operator (a window picker for in, a date picker for a date, an option list for a select, nothing at all for is empty).

filterCount counts the conditions alongside the old two keys, so the chip stays meaningful; a half-built condition counts for nothing, exactly as an empty is list does.

Two fixes this needed

  • popover() tore down its own successor. A popover that opened another popover was closed by the FIRST one's dismissal listener, which removed itself only when it fired. Measured: click Filter → Add condition → click the value box and the form vanished with document.querySelectorAll('.sp-pop').length === 0 before a character could be typed. Nothing had chained popovers before the condition builder, so the bug was latent rather than new. Fixed inside popover() by parking the listener's removal on the overlay teardown.
  • The condition form focuses its first control, rather than the popover container the keyboard trap focuses.

i18n

29 new keys × 8 catalogs, 626/626 in every locale, build-spaces-i18n.mjs --check green. Operator and window names are literal t() calls at their call sites (a switch, like fieldTypeLabel), never a map of English read back. The clause summary is a translatable template ({field} {op} {value}) rather than a hardcoded join, so word order is the catalogs' to choose — verified in the shell: Japanese renders "Priority が Urgent である", German "Priority ist Urgent".

Verification

node scripts/test-spaces.mjs                          all rigs passed
TZ=Pacific/Kiritimati node scripts/test-spaces.mjs    all rigs passed
TZ=Pacific/Niue      node scripts/test-spaces.mjs     all rigs passed
TZ=Europe/Berlin     node scripts/test-spaces.mjs     all rigs passed
cd spaces && node_modules/.bin/tsc --noEmit           clean
npm run build:single && shell-gate.mjs                conformance gate OK

69 new behavioural assertions in scripts/test-spaces-model.ts (999 → 1003 total in that rig; the section builds a four-page doc and asserts on the ROW IDS a filter selects, never on a boolean). test-spaces-model.ts is an ops-surface file — flagged per the shared-surface rule; no CI step added, the rig is already registered.

13 sabotages, all caught — each one breaks a single line, runs the rig, and is required to produce a SPECIFIC failure. Four of the first twelve proved nothing and were replaced rather than kept:

sabotage first assertion it fails
old is membership stops being applied "…and one that is not does not" (5 fail)
any allowed to reach the old keys "an old key still ANDs with the new list, whatever any says"
an unknown operator FAILS CLOSED "an unknown operator shows MORE, never fewer"
an unknown clause DROPPED before evaluation "…and under any it passes rather than being skipped"
date windows step by 86,400,000 ms of local time "the day before a spring-forward Monday is the Sunday"
the week start assumed rather than read "a Monday-start locale puts Wednesday 7 Jan in Mon 5 – Sun 11"
:title reads something else "a condition can ask about the TITLE"
an empty list stops counting as empty "an EMPTY LIST is empty — [] is the absence of labels"
the chip counts what it cannot apply "the chip counts only what actually narrows"
a summary shows the stored id "a summary names the field and shows the option LABEL"
contains matches the stored value "and on a select it reads the option LABEL"
where/any not registered as known keys "where and any are known keys HERE"
a half-built clause treated as real "the chip counts only what actually narrows"

The four replaced: a UTC-epoch date sabotage produced an equally CORRECT implementation rather than a broken one (replaced with a local-epoch one across a DST boundary, plus two new assertions); a weekStartDay fallback sabotage was unreachable because node's ICU always answers (replaced with one that ignores the locale entirely); a summary sabotage aimed at the wrong function; and a half-built-clause sabotage hit a line already unreachable through passesClauses.

Browser-verified on a uniquely-named copy over http://127.0.0.1:5311, marker checked (location.href + 14 starter pages), zero console errors:

  • condition built end-to-end through real keyboard input — Tab to the value box, type column, Tab, Return — writing {"where":[{"key":":title","op":"contains","v":"column"}]}, narrowing the board 5 → 1 card, chip Filter · 1
  • summaries render "Priority is Urgent" / "Status is Done"; one tap removes a condition; removing the last one deletes the key
  • Match all → Match any turns 0 rows into 2 on the same two conditions
  • the value control follows the operator: window select for in, input[type=date] for lt, absent for empty
  • an unknown operator shows all 5 rows AND the banner "A filter here is newer than this build and was not applied."
  • old is / open / both filters select 2 / 4 / 2 rows, unchanged
  • serialize() round-trips where + any; undo reverses the any-toggle as its own step

Size

277,769 → 282,677 B compressed (+4,908 B, +1.8%), measured against origin/main built side by side in /tmp.

Docs

spaces/CHANGELOG.md under [Unreleased] (appended at the tail), a docs/DECISIONS.md entry, and a new "Narrowing a view" section in docs/spaces-agents.md with the operator table — that file is a contract with people outside this repo and the view row now names sort/source/filter too.

Not fixed here

about.ts toMarkdown's rowsOf still uses issuesOf where viewRows(doc, source) is correct, so a has/under view exports the wrong rows to Markdown. Pre-existing, noted in the spaces zone notes, out of scope — it now passes r.page through like every other call site, and nothing else about it changed.

`ViewFilter` had two keys — `open` and `is` — so a view could ask "which of
these values" and nothing else, under five layouts that exist to hold books,
tasks and dates. "Published after 2020", "due this week", "not tagged draft",
"title contains onboarding" and "has no due date" were all unexpressible.

Adds `filter.where`, a FLAT list of `{key, op, v?}` conditions, and
`filter.any`, which ORs them instead of ANDing. Eleven operators, argued per
field type in spaces/src/query.ts: eq/ne, gt/gte/lt/lte, contains/notContains,
empty/notEmpty, and `in` over five relative date windows. A condition can name
a field key or `:title`.

Flat rather than nested, deliberately: a nested group needs a UI that can show
and unbuild a tree and the popover is a phone sheet, while nesting stays
available later as another additive key. `any` reaches `where` only — letting
it reach `is` or `open` would change what a file already on disk means.

Additivity, both directions:
- every pre-`where` filter runs through exactly the code it always did; the
  rig proves each old shape still selects exactly its old rows
- removing the last condition DELETES the key, so a view conditioned and
  cleared is byte-identical to one nobody touched (verified in the browser:
  the block's keys come back to id/type/html/layout/groupBy)
- an OLDER build sees unknown keys, shows a superset, and raises the banner it
  already had; a NEWER operator meeting this build does the same one level
  down — `unknownFilterOps` reports it, and it is treated as no constraint
  under AND and as PASSING under `any`, because skipping a clause in an OR
  would hide rows for a rule nobody can read

Dates never construct a Date from a string: a date field holds YYYY-MM-DD,
whose string order is chronological in every zone, and the windows are built
from journal.ts's todayISO/stepDay. The week start is read from
Intl.Locale.weekInfo (Monday fallback) and is viewer-scoped, never stored. No
eval, no new Function — fixed operator table, returns a boolean.

Also fixes a latent bug in editor.ts `popover()`: a popover that opened another
popover was torn down by the first one's own dismissal listener, which removed
itself only when it fired. Nothing had chained popovers before the condition
builder. And the condition form focuses its first control rather than the
popover container.

69 behavioural assertions in scripts/test-spaces-model.ts (ops surface),
asserting on ROWS; 13 sabotages, all caught. Rigs green under TZ=UTC,
Europe/Berlin, Pacific/Kiritimati and Pacific/Niue. Shell 277,769 -> 282,677 B
compressed (+1.8%), measured against origin/main built side by side.
@github-actions

Copy link
Copy Markdown

Build size

main (0100083) → spaces-query (7787df5)

app base PR change
bento/slides 677.9 KiB 677.9 KiB 0.0 KiB (0.00%)
bento/spaces 271.3 KiB 276.1 KiB +4.8 KiB (+1.77%)
bento/dash 424.1 KiB 424.1 KiB 0.0 KiB (0.00%)

Updated: 2026-09-10T00:20:20Z

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant