docs(filters): user manual — guide, cookbook, generated field reference

The filter manual (docs/user/filters/), folded in from the parked plan
now that the UI matches the backend (group ALL/ANY header toggle,
2344f568):

- index.md — the guide: the three homes of filters (drawer/saved
  filters, smart collections, dashboard sections), the books/episodes
  scope door, rule anatomy, value picking (chips with x, live counts
  under other rules), GROUPS via the new header toggle (+ the row
  chips as the fine-tuning tool), the live match count, today's
  defaults (reversed listening order, family-lens scoping,
  un-downloaded episodes), saving and shareable views.
- cookbook.md — seven exact-clicks recipes (born-reversed briefing,
  OR-across-authors, big-unread-fantasy, downloaded-unplayed,
  new-arrivals, dashboard shelf, shareable views). Every claim
  cross-checked against the generated reference — which caught the
  one wrong operator label in my first draft ("within last" vs the
  real "within the last"), the drift protection working as designed.
- fields.md — GENERATED from FilterFieldsPayload by
  TestFieldReferenceDocIsCurrent: the drift test fails the build if
  the registry ever changes without regenerating the docs (user
  decision: docs forced to match working code; FILTER_DOCS_REGEN=1
  regenerates).
- searching-and-filtering.md cross-links to the folder.
This commit is contained in:
John O'Keefe committed 2026-11-01 15:46:34 -05:00
1 parent 2344f5686a
commit 1ed1602e70
5 files changed
+1090

No files matched your search

+157
View File
@@ -0,0 +1,157 @@
package services
import (
"fmt"
"os"
"sort"
"strings"
"testing"
)
// The user manual's field reference (docs/user/filters/fields.md) is
// GENERATED from FilterFieldsPayload — the single source of truth the
// real menus render from — and pinned by this test: if the registry
// changes without regenerating the docs, this fails. Docs cannot drift
// from working code (2026-11-01, user decision).
//
// Regenerate with: FILTER_DOCS_REGEN=1 go test ./internal/services -run TestFieldReferenceDocIsCurrent
const fieldRefPath = "../../docs/user/filters/fields.md"
func TestFieldReferenceDocIsCurrent(t *testing.T) {
doc := GenerateFieldReferenceDoc()
if os.Getenv("FILTER_DOCS_REGEN") == "1" {
if err := os.WriteFile(fieldRefPath, []byte(doc), 0o644); err != nil {
t.Fatalf("write field reference: %v", err)
}
t.Log("rewrote " + fieldRefPath)
return
}
existing, err := os.ReadFile(fieldRefPath)
if err != nil {
t.Fatalf("docs/user/filters/fields.md is missing — regenerate with FILTER_DOCS_REGEN=1 go test ./internal/services -run TestFieldReferenceDocIsCurrent: %v", err)
}
if string(existing) != doc {
t.Fatal("docs/user/filters/fields.md is STALE — the filter registry changed without regenerating the docs. " +
"Run FILTER_DOCS_REGEN=1 go test ./internal/services -run TestFieldReferenceDocIsCurrent and commit the refreshed file.")
}
}
// kindPhrase — what the value cell looks like for a user, per value kind.
func kindPhrase(kind string) string {
switch kind {
case VKText:
return "Type words."
case VKNumber:
return "Type a number."
case VKDays:
return "Type a number of days."
case VKId:
return "Pick from a dropdown of real names — picks become removable chips, and you can pick several."
case VKFixed:
return "Choose from a fixed list of options."
case VKNone:
return "No value needed — the question is the whole rule."
}
return ""
}
// opPhrase — what each operator asks, in user words.
func opPhrase(op, kind string) string {
switch op {
case "contains":
return "contains these words"
case "not_contains":
return "does NOT contain these words"
case "is":
if kind == VKId {
return "is one of the ones you pick"
}
return "matches exactly"
case "is_not", "isn't":
return "matches anything but this"
case "in":
return "is one of the ones you pick (any one is enough)"
case "not_in":
return "is none of the ones you pick"
case "in_any":
return "is in at least one of the collections you pick"
case "none_of":
return "is in none of the collections you pick"
case "has":
return "has a value"
case "missing":
return "is empty or missing"
case "is_true":
return "is switched on"
case "is_false":
return "is switched off"
case "between":
return "falls between two numbers"
case "at_least":
return "is at least this"
case "at_most":
return "is at most this"
}
return "—"
}
func GenerateFieldReferenceDoc() string {
var b strings.Builder
b.WriteString("<!-- GENERATED by TestFieldReferenceDocIsCurrent (internal/services/filter_docs_test.go)\n")
b.WriteString(" from the filter registry — the same source the real menus render. DO NOT EDIT by hand:\n")
b.WriteString(" regenerate with FILTER_DOCS_REGEN=1 go test ./internal/services -run TestFieldReferenceDocIsCurrent -->\n\n")
b.WriteString("# Every filter field\n\n")
b.WriteString("This is the complete list of everything the filters can ask about, generated straight from the\n")
b.WriteString("same source the app's menus render — it cannot drift from what you see in the UI.\n\n")
b.WriteString("Filters come in two worlds:\n\n")
b.WriteString("- **Books** — rules about books and your reading of them.\n")
b.WriteString("- **Episodes** — rules about podcast episodes (feeds, downloaded, played).\n\n")
b.WriteString("Every rule is **a field + a question + your values**. The \"Ask\" column is the question;\n")
b.WriteString("the \"Your values\" column is what you provide. The **default** question is what a new rule\n")
b.WriteString("starts with.\n\n")
for _, scope := range []string{ScopeBooks, ScopeEpisodes} {
scopeTitle := "Books"
if scope == ScopeEpisodes {
scopeTitle = "Podcast episodes"
}
fmt.Fprintf(&b, "## %s\n\n", scopeTitle)
fields := FilterFieldsPayload(scope)
groups := []string{}
byGroup := map[string][]FilterFieldWire{}
for _, f := range fields {
if _, seen := byGroup[f.Group]; !seen {
groups = append(groups, f.Group)
}
byGroup[f.Group] = append(byGroup[f.Group], f)
}
sort.Strings(groups)
for _, g := range groups {
fmt.Fprintf(&b, "### %s\n\n", g)
for _, f := range byGroup[g] {
fmt.Fprintf(&b, "**%s** — %s\n\n", f.Label, kindPhrase(f.ValueKind))
lines := make([]string, 0, len(f.Ops))
for _, op := range f.Ops {
label := op.Label
if label == "" {
label = op.Key
}
def := ""
if op.Key == f.DefaultOp {
def = " *(default)*"
}
lines = append(lines, fmt.Sprintf("| %s | %s%s |", label, opPhrase(op.Key, f.ValueKind), def))
}
b.WriteString("| Ask | Means |\n| --- | --- |\n")
b.WriteString(strings.Join(lines, "\n"))
b.WriteString("\n\n")
}
}
}
b.WriteString("---\n\n")
b.WriteString("*Anything here that doesn't match what the app actually shows is a bug — this page is generated\n")
b.WriteString("from the app's own menus and a test fails if they ever disagree.*\n")
return b.String()
}