spaces: view filters that can ask a real question - #436
Open
nyblnet wants to merge 1 commit into
Open
Conversation
`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.
Build size
Updated: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The gap
passesFiltersupported exactly two things: anopenphase flag andis: {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.ViewFiltergains 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:
eq/neisalready does for arraysgtgteltltenumber, otherwise text — and a date'sYYYY-MM-DDsorts chronologically as text, so before/after/on are these under other namescontains/notContainsempty/notEmptyiscould never ask — an unset value is the absence of a value, not one of its valuesintodayweekmonthpastfutureTwo conditions on one number AND into a range.
paston 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
anyreaches onlywhere. The result isopen AND is AND (where, combined by all-or-any). Lettinganyreachisoropenwould change what a file already on somebody's disk means.Format additivity — both directions
wherefile could carry (open,is, list-valuedis, both keys together, an empty value list) over the same four rows and asserts the exact id string each selects.whereandany; 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 toid, type, html, layout, groupBy.where/anysees unknown keys, shows a SUPERSET, and raises the banner it already had for a newer sort. That is the existing trade, unchanged.unknownFilterOps(the sibling ofunknownFilterKeys) reports it, render.ts folds it into the same banner, and the clause is treated as no constraint under AND — and as passing underany, 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 infields.ts(isOpenPhasecounts an unknown status as open; an emptyislist is no constraint).Dates and timezones
new Date('2026-01-01')appears nowhere in query.ts and must not. Adatefield 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'stodayISO/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
wheremay be a string; a clause may benull, a number, or missing itsop; a key may be__proto__ortoString;vmay 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 throughSet.has, so a word out of a mailed file can never reach a prototype. Noeval, nonew Function— a fixed operator table that can only return a boolean, for the reasoncalc.ts's header argues at length.The UI
openViewFilterkeeps 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 forin, a date picker for a date, an option list for a select, nothing at all foris empty).filterCountcounts the conditions alongside the old two keys, so the chip stays meaningful; a half-built condition counts for nothing, exactly as an emptyislist 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 withdocument.querySelectorAll('.sp-pop').length === 0before a character could be typed. Nothing had chained popovers before the condition builder, so the bug was latent rather than new. Fixed insidepopover()by parking the listener's removal on the overlay teardown.i18n
29 new keys × 8 catalogs, 626/626 in every locale,
build-spaces-i18n.mjs --checkgreen. Operator and window names are literalt()calls at their call sites (a switch, likefieldTypeLabel), 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
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.tsis 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:
ismembership stops being appliedanyallowed to reach the old keysanysays"anyit passes rather than being skipped":titlereads something elsecontainsmatches the stored valuewhere/anynot registered as known keyswhereandanyare known keys HERE"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
weekStartDayfallback 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 throughpassesClauses.Browser-verified on a uniquely-named copy over
http://127.0.0.1:5311, marker checked (location.href+ 14 starter pages), zero console errors:column, Tab, Return — writing{"where":[{"key":":title","op":"contains","v":"column"}]}, narrowing the board 5 → 1 card, chipFilter · 1in,input[type=date]forlt, absent foremptyis/open/ both filters select 2 / 4 / 2 rows, unchangedserialize()round-tripswhere+any; undo reverses the any-toggle as its own stepSize
277,769 → 282,677 B compressed (+4,908 B, +1.8%), measured against
origin/mainbuilt side by side in/tmp.Docs
spaces/CHANGELOG.mdunder[Unreleased](appended at the tail), adocs/DECISIONS.mdentry, and a new "Narrowing a view" section indocs/spaces-agents.mdwith the operator table — that file is a contract with people outside this repo and theviewrow now namessort/source/filtertoo.Not fixed here
about.ts toMarkdown'srowsOfstill usesissuesOfwhereviewRows(doc, source)is correct, so ahas/underview exports the wrong rows to Markdown. Pre-existing, noted in the spaces zone notes, out of scope — it now passesr.pagethrough like every other call site, and nothing else about it changed.