diff --git a/CLAUDE.md b/CLAUDE.md index d69bbaf..458045c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,8 +87,8 @@ Decomposed into a container component, presentational children, and three signal - **`VocabularyExercisesWordListComponent`** - the presentational list screen. Receives the container's shared dataSource/selection/signals as inputs and forwards user intents as outputs; wires its template's paginator/sort onto the shared dataSource via `@ViewChild` setters. - **Signal stores** - `ReviewSessionStore` (queue, cursor, auto-mode interval, per-word `ratingMap`, progress computeds), `SpellingSessionStore` (queue, per-letter reveal state, results), and `QuizSessionStore` (single-choice question queue, per-question answer state, results). Plain `@Injectable()` classes (no state library), provided in the container's `providers` so the container and session screens share one instance per page instance. They are imported directly, not via the barrel. - **Session screens** - `review-session`, `spelling-session`, `spelling-result`, `quiz-session`, and `quiz-result` components inject their store directly (no state inputs) and each registers a `document:keyup` `@HostListener`, so keystroke handling only lives while that screen is mounted. -- **Dialogs** - one mode-driven select dialog (By Count / Free Selection / By Word), plus `reviewoptions`, `spellingoptions`, `worksheetoptions`, `quizoptions`, `word-filter`, and `rating-filter` dialogs. Filter dialogs clone their seed conditions so Cancel cannot mutate them; options dialogs round-trip `currentSettings` so reopening shows the last picks. -- **Filter pipeline** - free-text + word conditions + rating conditions combine into a `VocabularyListFilter` (`interfaces/vocabulary.ts`), serialized as JSON into `MatTableDataSource.filter`; the row predicate delegates to the pure `matchVocabularyListFilter()` (closing over the rating map). After async rating loads, re-assign `dataSource.filter = dataSource.filter` to force re-filtering. +- **Dialogs** - one mode-driven select dialog (By Count / Free Selection / By Word), plus `reviewoptions`, `spellingoptions`, `worksheetoptions`, `quizoptions`, and the **shared** `filter` dialog (`src/app/shared/filter-dialog/` — `SharedFilterDialogComponent`, design in `docs/reusable-filter-dialog-design.md`), opened with the page's `VOCABULARY_FILTER_PROPERTIES` schema and an actslib `IFilterDefinition` seed; Submit returns the edited definition, Cancel/backdrop/Esc return `undefined`. It is a project-wide component (all four list pages — vocabulary, knowledge, Chinese and translate — now filter through it, each with its own `*_FILTER_PROPERTIES` schema), not a vocabulary-page file. Options dialogs round-trip `currentSettings` so reopening shows the last picks. +- **Filter pipeline** - free-text + the actslib **`IFilterDefinition`** (word/rating conditions in AND/OR-joined, nestable groups, produced directly by the shared dialog — no page-side tree model or translation step) combine into a `VocabularyListFilter` (`interfaces/vocabulary.ts`), serialized as JSON into `MatTableDataSource.filter`; the row predicate delegates to the pure `matchVocabularyListFilter()` (closing over the rating map), which runs `FilterUtility.MatchFilter` against a case-folded target `{ enword, cnword, rating }` (string condition values are folded by each property's `prepareValue` at dialog emit time; free-text stays a hand-written cross-field check). After async rating loads, re-assign `dataSource.filter = dataSource.filter` to force re-filtering. - **Ratings** - list ratings live in a `contentRatingMap` signal; the review store captures only server-confirmed ratings and returns them on `quit()`, which the container merges back into the map. Temporary (uploaded) content gets a synthetic `LearningContent` with a negative id, and rating calls are disabled for it. - **Quiz exercise** - the Exercises-menu Quiz item reuses the shared word-queue selection (table selection, else filter + `prepareWordQueue`), then builds single-choice questions via the pure `buildVocabularyQuizQuestions()` (`interfaces/vocabulary.ts`): EN word -> pick the CN explanation among 4 candidates (`en2cn`), or CN explanation -> pick the EN word (`cn2en`). Distractors come from the visible (filtered) rows, deduplicated by displayed text; questions that cannot gather one distinct alternative are skipped. diff --git a/docs/knowledge-chinese-pages-review.md b/docs/knowledge-chinese-pages-review.md new file mode 100644 index 0000000..b295289 --- /dev/null +++ b/docs/knowledge-chinese-pages-review.md @@ -0,0 +1,173 @@ +# Knowledge Exercises & Chinese Exercises Pages — Logic Review + +- **Date:** 2026-08-29 +- **Branch:** `feat/rating-unrated-semantics` +- **Scope:** `src/app/pages/knowledge-exercises/**`, `src/app/pages/chinese-exercises/**`, and the + filter pipeline they share (`src/app/interfaces/knowledge-list-filter.ts`, `learnchinese.ts`, + `ui-common.ts` `matchRating`, `vocabulary.ts` `RatingCondition`). +- **Method:** manual read of containers, list children, dialogs, converters; verified against the + installed `@angular/material` 21 source and real content data + (`knowledgebuilder-content/learnchinese/`); focused tests run via `ng test` (158 tests, 5 spec + files, all passing at review time). +- **Re-verified:** 2026-08-29, after the knowledge filter-UI merge (Content/Rating dialogs → one + field-selecting filter dialog; filter fields aligned to table columns). Findings and line refs + refreshed against the current tree; full suite green (1808 tests / 75 spec files) and the + production build passes. +- **Status legend:** each finding carries a status (`open` / `fixed` / `wontfix`) for tracing. + +--- + +## Findings (real issues) + +### 1. [Chinese] Recite flow is a dead end — status: open + +- **Where:** `chinese-exercises.component.ts:590` (`onStart() {}`) +- **What:** Exercises ▾ → Recite opens `ChineseExercisesOptionsDialogComponent`, collects + level/count/allowEmptyAnswer, then calls `onStart()` — an empty stub. Nothing happens. + `this.setting` (`ChineseReciteOption`) is write-only state. +- **Origin:** pre-existing on `main` (the recite screen was removed earlier); the menu item and + dialog survived it. +- **Suggestion:** remove the Recite menu item + options dialog + `setting`, or rewire the flow to + an actual target. + +### 2. [Chinese] Print options collected but silently ignored — status: open + +- **Where:** `chinese-exercises.component.ts:613-619` (copying dialog results) vs + `chinese-exercises.component.ts:658-667` (`onPrint` building `execPrintSetting`). +- **What:** `onPrintWithOptions` stores `respectRetentionCurve`, `printExecDate`, `execDate`, + `printEntryDate` into `printSetting`, but `onPrint` builds a fresh `execPrintSetting` that + hardcodes `printEntryDate: true` and never reads the other three — + `KnowledgeExercisePrintOption` (`questionbank-base.ts:1374`) has no such fields. The dialog's + exec-date radio ("respect retention curve") has no effect. +- **Origin:** pre-existing on `main`. +- **Contrast:** the knowledge print dialog is fine — every option flows through + `this.printSetting` into `uiService.setSelectedExerciseItem`. +- **Suggestion:** either extend `KnowledgeExercisePrintOption` and implement the retention/exec-date + behavior in the display/print renderer, or drop the dead controls from + `chinese-exercises-printoptions-dialog.html`. + +### 3. [Chinese] v2 multi-segment items print as "undefined" — status: open + +- **Where:** `learnchinese.ts:277-278` (`convertChineseReciteItemToKnowledge`, FillInTheBlank + branch): `qitem.question += item.content`. +- **What:** multi-segment items (`contentlength > 0`, no `content` field) produce + `question = ", . undefined"` and no answers. The Dictation branch loops + content1..content39; the FillInTheBlank branch does not. +- **Data evidence:** 10 items in `knowledgebuilder-content/learnchinese/gaozhongchangshi.json` + (a version-2 file) have `contentlength` and no `content`; printing that file without a narrow + selection includes them. +- **Origin:** pre-existing (the converter is unchanged on this branch), data-verified during this + review. +- **Suggestion:** in the FillInTheBlank branch, fall back to the joined segments (same logic as + `getChineseReciteItemDisplayContent`) when `content` is absent. + +### 4. [Chinese] Filter cannot see multi-segment content — status: open + +- **Where:** `learnchinese.ts:90-91` (`chineseConditionFieldText`, `content` case) and + `learnchinese.ts:110-111` (free-text haystack) — both read `item.content` only. +- **What:** the list column renders joined content1..N segments + (`getChineseReciteItemDisplayContent`), so users can see text that free text and the Content + condition cannot match. Affects the same 10 multi-segment items as finding 3. +- **Origin:** introduced by this branch (the filter is new). +- **Suggestion:** build the searchable content from the same joined-segments helper inside + `matchChineseListFilter`. + +### 5. [Both] Race: old file's rows can be rated under the new file's contentId — status: fixed (knowledge, 2026-08-29) / open (Chinese) + +- **Fix (knowledge):** `studyContentId` is now reset to 0 on file switch and only assigned in the + content `next` handler (after the token check), so clicks on the still-visible old rows hit the + `studyContentId <= 0` guard and are dropped; `getRatings` uses the local `contentId`. Regression + test: "should ignore rating clicks on stale rows while the new file is loading". + +- **Where:** `chinese-exercises.component.ts:340`, `knowledge-exercises.component.ts:361` + (`this.studyContentId = selectedContent.id` before content resolves). +- **What:** on file switch, `studyContentId` is assigned immediately while the previous file's rows + stay visible until the new content's `next`/`error`. A rating click in that window upserts + `(newContentId, oldItemId)` — persisting a rating into the wrong file's namespace. The error path + clears rows but cannot recall the already-issued request. +- **Probability:** narrow window (needs a slow content load plus a user click), but it is exactly + the failure class the error-path comments guard against. +- **Suggestion:** keep `studyContentId = 0` until the content `next` arrives (or disable the rating + toggles while a load is in flight). + +### 6. [Both] Race: slow `getRatings` response can revert a fresh rating — status: fixed (knowledge, 2026-08-29) / open (Chinese) + +- **Fix (knowledge):** the `next` handler overlays both the current `contentRatingMap` (covers + already-saved clicks) and `pendingContentRatings` (covers in-flight clicks) on top of the fetched + server list before `contentRatingMap.set`. Regression test: "should keep locally applied ratings + when a stale getRatings response lands". + +- **Where:** `chinese-exercises.component.ts:389-395`, `knowledge-exercises.component.ts:408-425` + (the `getRatings` `next` handler rebuilding `contentRatingMap`). +- **What:** if the initial `getRatings` fetch lands *after* an early `upsertRating` succeeded, the + map is rebuilt from the stale server list without the just-saved rating; + `pendingContentRatings` was already deleted on the upsert's success, so nothing re-applies it and + the toggle visually reverts to the stale server value. +- **Suggestion:** in the `next` handler, re-apply any `pendingContentRatings` entries on top of the + fetched map before `contentRatingMap.set`. + +## Minor findings + +### 7. [Knowledge] Answer panel persists across prev/next — status: fixed (2026-08-29) + +- **Fix:** `onPreviousItem`/`onNextItem` now also reset `showDetailAnswer`, matching the hint-flag + handling. Regression test: "onNextItem/onPreviousItem should hide the previous item's answer + panel". + +`onPreviousItem`/`onNextItem` (`knowledge-exercises.component.ts:729-747`) reset the hint flags and +markdown but not `showDetailAnswer`, so the next item's answer is shown without pressing Toggle +Answer. Possibly intentional for studying; inconsistent with the hint handling either way. + +### 8. [Knowledge] `onPreviewCore` mutates cached source rows — status: fixed (2026-08-29) + +- **Fix:** the print queue is now built from shallow copies (`{ ...item, order }`, sub-items + copied too); the `LearningContentService`-cached row objects are no longer touched. Regression + test: "should renumber copies and leave the cached source rows untouched". + +`knowledge-exercises.component.ts:632-653` renumbers `item.order` on the row objects from +`dataSource.data`, which are the `LearningContentService` per-fileUrl cached JSON objects — the +mutation persists in the cache across visits. The Chinese flow correctly renumbers freshly +converted copies (`convertChineseReciteItemToKnowledge` output) instead. + +### 9. [Both] IME composition vs live filtering — status: fixed (knowledge, 2026-08-29) / open (Chinese) + +- **Fix (knowledge):** the free-text input moved from `(keyup)` to `(input)` plus a + `compositionstart`/`compositionend` guard — pinyin fragments are suppressed and the final text is + emitted on compositionend (value read from the event target, no ngModel race). Regression test: + "should suppress live filtering during IME composition" (list spec). + +Free text applies on `(keyup)`; typing Chinese via an IME fires filtering for intermediate pinyin +fragments. Composition-aware filtering (compositionstart/end guard or `(input)`-based) would be +smoother — notable on a Chinese-content page. + +### 10. [Both] List state resets when returning from detail — status: fixed (knowledge, 2026-08-29) / open (Chinese) + +- **Fix (knowledge):** the list screen is no longer destroyed by the mode switch — the container + keeps it mounted and toggles it with `[hidden]`, rendering detail/extrainfo via `@if`. Paginator + page/size and sort state (and scroll position) now survive a detail round-trip for free; the + `appliedFreeText`/`selectionCount` seeds remain but only matter at first construction. + Regression test: "should keep the list screen mounted (hidden) during detail visits". + +Entering detail/extra-info destroys the list child (`@switch` in +`knowledge-exercises.component.html`); returning re-creates paginator (page 0) and sort (none). +Free text and selection survive by design (`appliedFreeText` seed + `selectionCount` seed), but +page/sort do not. Cosmetic. + +## Verified non-issues (checked and found correct) + +- `dataSource.filter = this.dataSource.filter` self-assignment re-filter trick: safe on Material 21 + — the setter publishes to a `BehaviorSubject` unconditionally (verified in + `@angular/material/fesm2022/table.mjs`). +- Select dialogs (both pages): count/offset clamped as defense-in-depth; By-ID no-match paste can + neither close the dialog nor wipe the existing selection. +- Dialog Cancel semantics: seed conditions cloned; `undefined` (Cancel/backdrop/Esc) leaves state + untouched. +- Rating toggle deselect-restore (`event.value < 1` → restore group value) and stale-upsert + dropping via `pendingContentRatings` are sound. +- Unrated-as-0 semantics: `matchRating` compares numerically; `< 1` matches unrated, `>= 1` matches + rated — consistent with the branch's design (`ui-common.ts`). +- Knowledge Print button disabled on empty selection; detail prev/next buttons bounds-disabled. +- Filter/selection survive list-child re-creation (free-text reseed, `selectionCount` seed). +- Focused tests passing at review time: `ng test` — 158 tests / 5 spec files + (both page containers, `learnchinese.spec.ts`, `knowledge-list-filter.spec.ts`, + `ui-common.spec.ts`). diff --git a/docs/reusable-filter-dialog-design.md b/docs/reusable-filter-dialog-design.md new file mode 100644 index 0000000..2b489b7 --- /dev/null +++ b/docs/reusable-filter-dialog-design.md @@ -0,0 +1,598 @@ +# Design: Shared Filter Dialog (reusable condition-tree filter editor) + +Status: **implemented; vocabulary page migrated (Phase 1 + 2)** — the shared +dialog lives in `src/app/shared/filter-dialog/` and the vocabulary page uses it +(`VOCABULARY_FILTER_PROPERTIES` schema in `interfaces/vocabulary.ts`); the old +`vocabulary-exercises-filter-dialog.*` files and the `VocabularyFilterGroup` +model are deleted. Phases 3–5 (knowledge, Chinese, translate adoption) are +pending. Implementation deltas from this doc, all deliberate: +- the leaf holds **three value slots** (`single` / `between` / `choices`) + rather than the five named fields of §6.1; +- the date editor is a native `` (no `matDatepicker`, so the + dialog needs no date-adapter providers); +- summaries render **comparison symbols** (`>=`, `<`) for numeric/date + properties and word labels otherwise, keeping rating phrases compact; +- the join word in summaries is **translated** (`common.joinAnd`/`joinOr`) + rather than hardcoded English, matching the localization-first rule. + +Author: Claude Code session 2026-08-30 +Base implementation: the (now deleted) vocabulary filter dialog, which this +component generalizes and replaced. + +--- + +## 1. Purpose & scope + +One project-wide dialog for defining list-page filters as a **condition tree** +(SQL-WHERE shape: leaves = property conditions, inner nodes = AND/OR joins), +covering all six exercise pages. The dialog is configured per page with a +**property schema** whose operator set derives from actslib's filter semantics; +its seed and result are actslib-native (`IFilterDefinition`), so pages store, +translate-free, and evaluate exactly what the dialog returns. + +The name "Filter Dialog" (not "filter *options* dialog") avoids a collision +with the existing options dialogs (`reviewoptions`, `spellingoptions`, …), +which configure exercise options, not filters. + +### Goals + +1. **actslib-driven operators.** Each property's allowed operations are derived + from actslib (`FilterOperation` + the per-kind support matrix) and narrowed + by a per-page whitelist. Two special editor cases per the brief: + - **enum properties** render their value editor as a **multiple-choice list** + (checkboxes), compiled to actslib conditions on Submit (§7.3); + - **`Between`** renders **two inputs** (low + high) (§7.2). +2. **mat-tree + detail-pane design**, matching the vocabulary dialog: tree + navigator (left), editor for the selected node (right), draggable splitter, + insert/delete toolbar, live expression preview, Submit gated by validation. +3. **Replace, then unify**: first adopter is the vocabulary page (replacing + `VocabularyFilterGroup` + its bespoke dialog); the knowledge, Chinese and + translation pages' five legacy filter dialogs follow (§12). + +### Non-goals + +- The **free-text search box** stays on each page's filter bar (hand-written + cross-field matching; never enters the dialog). +- **Where values come from** (row fields vs. the user's rating from + `contentRatingMap`) is the page's concern — pages keep evaluating against a + synthesized target. +- No negation / NOT groups: actslib `FilterUtility` cannot express them. +- No persistence of filter presets (in-memory per page, as today). + +--- + +## 2. Current state (what gets replaced) + +| Page | Dialog today | Model today | Notes | +|---|---|---|---| +| vocabulary | `vocabulary-exercises-filter-dialog` | tree (`VocabularyFilterGroup`), edited via bespoke node/row copies | **reference design**; tree + panel + validation already built | +| knowledge-exercises | `knowledge-exercises-filter-dialog` | flat rows (`KnowledgeCondition[]` + `RatingCondition[]`), AND-only | **migrated** (shared dialog over `KNOWLEDGE_FILTER_PROPERTIES`; the `itemType` enum multi-select is §7.3's proof) | +| chinese-exercises | `chinese-exercises-content-filter-dialog` + `-rating-filter-dialog` | flat condition lists | two dialogs → one — **migrated** (shared dialog over `CHINESE_FILTER_PROPERTIES`) | +| translate-exercises | `translate-exercises-word-filter-dialog` + `-rating-filter-dialog` | flat `SentenceCondition[]` + `RatingCondition[]` | two dialogs → one — **migrated** (shared dialog over `SENTENCE_FILTER_PROPERTIES`) | + +Common shape: each page hardcodes its field union (`'enword' | 'cnword' | +'rating'`…), its operator list, its row editor template, and a translation +function to actslib. All of that becomes the **property schema** input; the +dialog owns tree editing, validation, and the `IFilterDefinition` I/O. + +--- + +## 3. Design decisions at a glance + +| # | Decision | Rationale | +|---|---|---| +| D1 | Seed + result are **actslib `IFilterDefinition`** | Pages already evaluate it (`FilterUtility.MatchFilter`); kills every page-specific dialog model and the `VocabularyFilterGroup` ↔ definition translation. The dialog is generic precisely because its I/O is the evaluator's language. | +| D2 | **Property schema** passed via `MAT_DIALOG_DATA`, operators defaulted per kind from actslib's matrix, narrowed by whitelist | "allowed options per property" without every page re-listing `>`/`>=`/… ; whitelist still controls what's *offered* (e.g. rating offers only `=`). | +| D3 | Enum multi-select compiles to **one leaf that emits an OR-of-`Equal` group**; seeds fold back | actslib has no `In` operation; OR-of-equals is the only faithful encoding, and `enumValues` per condition keeps actslib's enum validation. Fold-back keeps round-trips editable (§7.3). | +| D4 | Valueless custom operators via **`customOperators` hook** (`emit` + `recognize`) | Vocabulary's `isPhrase` (→ `Contains ' '`) is app semantics actslib can't express; the hook keeps the dialog reusable without hardcoding word knowledge (§7.4). | +| D5 | Keep the vocabulary editor-state pattern: numeric-id nodes, **reference `trackBy`**, **id `expansionKey`**, all edits **immutable through the root signal** | These are load-bearing CDK facts, not style choices (see §6.3 and the bug history); encoding them in the shared component prevents re-introducing them page by page. | +| D6 | `prepareValue?` hook per property for case-folding / trimming | actslib string comparison is case-sensitive; the vocabulary page lowercases folded values *and* folded row fields. Keeping the hook on the property lets the page decide match semantics while the dialog stays content-agnostic (§8.4). | +| D7 | Validation rules move into the dialog model, same contract as today: blank/missing values and non-branching nested groups block Submit; root exempt | Just implemented in the vocabulary dialog; promoted verbatim to shared code (§9). | + +--- + +## 4. Component overview + +``` +src/app/shared/filter-dialog/ +├── index.ts # public surface barrel +├── filter-dialog.component.ts # dialog shell (MAT_DIALOG_DATA consumer) +├── filter-dialog.component.html # mat-tree + splitter + detail pane +├── filter-dialog.component.scss # copied from the vocabulary dialog +├── filter-dialog.component.spec.ts # DOM tests +├── filter-dialog-model.ts # editor types + ALL pure logic (seed, +│ # mutate, validation, emit, summarize) +└── filter-dialog-model.spec.ts # pure-function tests (no Angular) +``` + +- `SharedFilterDialogComponent`, selector `app-filter-dlg`, standalone, + `OnPush`, template/SCSS imported per project conventions. +- **All tree logic lives in `filter-dialog-model.ts` as pure functions over + plain objects** (the vocabulary component's private methods promoted to + module functions taking explicit args). The component is then thin: hold the + `root`/`selectedId` signals, call model functions, wire the template. This + makes the interesting logic testable without `TestBed` and reusable for a + future flat-mode variant. +- Imports: `MatTree`/`MatNestedTreeNode`/`MatTreeNodeDef`/`MatTreeNodeOutlet`, + `FormsModule`, Material form fields/select/checkbox-list, `TranslocoModule`. +- Naming: types are `Shared…` (prefix `FilterDialog…`) to avoid clashing with + the per-page `KnowledgeFilterDialogRow` etc. during the migration window. + +## 5. Public contract + +```ts +import type { EnumLike, FilterOperation } from 'actslib'; +import type { IFilterCondition, IFilterDefinition } from 'actslib'; + +/** What kind of values a property carries. Drives the default operator list + * (§5.2), the value editor (§7), and the seed/emit dispatch. */ +export type FilterPropertyKind = 'string' | 'number' | 'date' | 'enum'; + +/** One choice of an enum property's multiple-choice editor. */ +export interface FilterEnumChoice { + value: string | number; + labelKey: string; // i18n key, translated by the dialog +} + +/** A valueless, app-specific operator (e.g. vocabulary 'isPhrase'). The page + * supplies the actslib encoding and the fold-back recognizer. */ +export interface FilterCustomOperator { + /** editor-local id, never crosses the dialog boundary */ + id: string; + labelKey: string; + /** actslib condition this operator emits on Submit */ + emit(property: string): IFilterCondition; + /** true when `condition` is one of this operator's emissions (seed fold-back) */ + recognize(condition: IFilterCondition): boolean; +} + +/** One filterable property of the page's target shape. */ +export interface FilterableProperty { + /** actslib condition property name (matched against the evaluated target) */ + key: string; + labelKey: string; + kind: FilterPropertyKind; + /** offered operators; default = per-kind actslib set (§5.2), ∩ when given. */ + operations?: FilterOperation[]; + /** kind 'enum': actslib enum validation, passed through to each condition. */ + enumValues?: EnumLike; + /** kind 'enum': choices rendered as the multiple-value editor (§7.3). */ + choices?: FilterEnumChoice[]; + /** valueless operators appended to the operator select (§7.4). */ + customOperators?: FilterCustomOperator[]; + /** number/date editors: input constraints (ui only; not enforced on text input). */ + numberRange?: { min?: number; max?: number }; + /** transforms the raw editor value before it is emitted (D6: trim + lowercase). */ + prepareValue?: (value: string | number) => string | number; +} + +export interface FilterDialogData { + properties: FilterableProperty[]; + /** seed = the filter currently in effect; empty/undefined starts blank */ + root?: IFilterDefinition; + /** deepest group level the toolbar offers; default 4 */ + maxDepth?: number; + /** dialog title key; default 'common.editFilter' */ + titleKey?: string; +} + +export interface FilterDialogResult { + root: IFilterDefinition; +} +``` + +The **uniform close contract is unchanged**: Submit → `{ root }`; +Cancel/backdrop/Esc → `undefined` (caller leaves state untouched). + +### 5.1 Operator derivation from actslib + +actslib's support matrix (FilterUtility docs + `MatchCondition`): + +| kind | default operators (actslib order) | +|---|---| +| `string` | `BeginsWith`, `Contains`, `Equal`, `EndsWith`, `>` `>=` `<` `<=` (lexicographic), `Between` | +| `number` | `>`, `>=`, `=`, `<=`, `<`, `Between` | +| `date` | same as `number` (actslib detects dates at runtime) | +| `enum` | `Equal` only at the *leaf* level (multi-choice compiles to OR-of-`Equal`; §7.3) | + +Rules: + +- The dialog offers `customOperators` **in addition** to the (whitelist-narrowed) + default list — `hasValue: false` by definition (they encode the value). +- Ordering follows the table above (familiar → exotic); pages that care pass + an explicit `operations` list, which also fixes order. +- A property whose effective operator list is empty is a schema bug: dev-mode + `console.warn`, property skipped in the select. + +### 5.2 Per-page schema examples + +```ts +// vocabulary page (the migration's first target) +const VOCABULARY_FILTER_PROPERTIES: FilterableProperty[] = [ + { key: 'enword', labelKey: 'vocabularyExercises.word', kind: 'string', + operations: [BeginsWith, Contains, Equal, EndsWith], + customOperators: [IS_PHRASE], // emit: Contains ' '; recognize: op=Contains && lowValue=' ' + prepareValue: v => String(v).trim().toLowerCase() }, + { key: 'cnword', labelKey: 'chinese', kind: 'string', + operations: [BeginsWith, Contains, Equal, EndsWith], + prepareValue: v => String(v).trim().toLowerCase() }, + { key: 'rating', labelKey: 'rating', kind: 'number', + operations: [GreaterThan, LargerOrEquals /* >= */, Equal, LessOrEquals, LessThan], + numberRange: { min: 0, max: 5 } }, +]; +``` + +(The rating property replaces today's hand-mapped `RatingOperatorEnum`: the +dialog works in actslib ops directly; `matchRating` stays for legacy paths +until phase 4.) + +## 6. Editor state model + +### 6.1 Types + +```ts +/** One editable leaf. All value kinds COEXIST (today's VocabularyFilterDialogRow + * pattern): switching property/operator never loses input, and the template + * only shows the controls the current dispatch selects. */ +export interface SharedFilterDialogLeaf { + id: number; + propertyKey: string; + /** a FilterOperation value, or a customOperator id */ + operator: string; + textValue: string; // string editor + numberValue: number | null; // number/date single-value editor + lowValue: number | null; // Between bounds + highValue: number | null; + selectedChoices: (string | number)[]; // enum editor (§7.3) +} + +export interface SharedFilterDialogNode { + id: number; + join: FilterJoinType; + members: Array; +} +``` + +### 6.2 Pure functions in `filter-dialog-model.ts` + +| function | role | +|---|---| +| `seedTree(def: IFilterDefinition \| undefined, schema): SharedFilterDialogNode` | copy-in: conditions → leaves (fold-back: custom `recognize`, Between, enum OR-of-equals → one multi-choice leaf, single value); nested groups → nodes; **structure preserved at any depth**; never mutates the caller's def | +| `emitTree(root, schema): IFilterDefinition` | Submit output: leaves → conditions/groups (§7 dispatch); drops nothing (validation already guarantees completeness); root may emit `conditions: []` (= match-all = cleared filter) | +| `insertMember / deleteMember / patchNode / patchLeaf` | the vocabulary `mutateNode`/`replaceRow` immutables, generalized: every edit returns a new object along the mutation path | +| `emptyLeaf(schema): SharedFilterDialogLeaf` | new row = first property, its first operator, blank values | +| `validateTree(root, schema): ValidationState` | `hasMissingValue` + `invalidGroupIds` (§9) | +| `summarizeFilterDefinition(def, schema, labels): string` | preview + menu label (parenthesized notation, per-group join, choice lists as `a/b/c`, Between as `low ≤ x ≤ high`); mirrors today's `summarizeVocabularyFilterTree` | + +`patchLeaf` replaces a leaf by id inside its parent (same `parentIdOf` + +`members.map` trick as the vocabulary dialog). + +### 6.3 The CDK tree invariants (load-bearing — do not "simplify") + +Proven twice in the vocabulary dialog's bug history; the shared component must +carry them forward verbatim: + +1. **`[trackBy]` = object reference** (`(_i, m) => m`). CdkTree's nested nodes + read their children *once* at view creation; its differ defaults trackBy to + the expansion key. An id-keyed differ therefore "keeps" mutated (replaced) + nodes whose views render stale children forever. Reference keys make every + immutable replacement re-create the affected views. +2. **`[expansionKey]` = node id** + `[isExpanded]="true"` per + `mat-nested-tree-node` — always-expanded navigator that survives view + re-creation (expansion model keyed by id; recreated groups stay open). +3. **Every edit flows through the root signal** (`root.update(...)`); nothing + mutates editor objects in place. The dialog is OnPush: an in-place write + (e.g. `[(ngModel)]="leaf.textValue"`) dirties no signal, so tree labels and + the preview show stale values. All detail-pane bindings are + `[ngModel]` + `(ngModelChange)` → patch handlers. +4. `treeData = computed(() => [root()])` as `[dataSource]` — one top-level row + (the root node); member ids are unique editor-local counters. + +## 7. Value editors (detail pane), by dispatch + +The detail pane's **property select** drives everything: picking a property +swaps the operator select contents (per §5.1) and the **value editor** below. +Dispatch table: + +| effective editor | condition | controls | +|---|---|---| +| text | `kind: string`, valued operator | `matInput` (single) | +| number | `kind: number`, single-value operator | `matInput type=number` with `numberRange` | +| date | `kind: date`, single-value operator | datepicker (`MatDatepickerModule`, `date-fns` adapter per project convention) | +| **between** | any valued kind, `operation = Between` | **two inputs** (low, high) — §7.2 | +| **enum choices** | `kind: enum` (operator fixed to `Equal`) | **multiple-choice checkbox list** — §7.3 | +| custom (valueless) | `operator ∈ customOperators` | none ("this needs no value" hint row) | + +### 7.1 State co-location + +All five value fields live on the leaf (§6.1) and keep their values across +switches (the user's phrase→contains→phrase round-trip must not lose typed +text). `emitTree` reads only the one field the dispatch selects — the others +are discarded, mirroring the existing dialog. + +### 7.2 Between + +- Two inputs (`lowValue`, `highValue`); actslib `Between` is **inclusive on + both bounds** and examines both. +- Validation: both filled; `low <= high` (numeric and date compare; string + Between compares lexicographically per actslib — allowed, no extra rule). +- Emit: `{ property, operation: Between, lowValue, highValue }` (+ + `enumValues` passthrough if the property has it). +- Seed fold-back: a condition with `operation === Between` populates + `lowValue`/`highValue` directly (no group involved). + +### 7.3 Enum: multiple-choice (the first special case) + +The leaf for a `kind: 'enum'` property offers `choices[]` as checkboxes +(`MatSelectionList`+`MatCheckbox` or a checkbox group; label = +`t(labelKey)`); its operator select shows `Equal` (disabled select — operator +is implied by the kind). + +**Emit (Submit):** + +- 1 value chosen → single condition + `{ property, operation: Equal, lowValue: v, enumValues }` +- N > 1 values → a nested group + `{ join: OR, conditions: [ {Equal, v₁, enumValues}, …, {Equal, vₙ, enumValues} ] }` +- 0 values → **invalid** (§9: enum leaf must pick at least one). There is no + "inactive" escape: an all-empty tree is the way to clear the filter. + +Rationale: actslib has no `In` operation; OR-of-`Equal` is the only faithful +encoding of "row's enum value ∈ chosen set", and attaching `enumValues` to +each condition reuses actslib's enum validation (non-members never match). + +**Seed fold-back:** when `seedTree` meets an **OR group whose every member is** +`Equal` **on the same enum property**, it collapses it into ONE multi-choice +leaf (values = the `lowValue`s). The fold is deliberately strict (direct +members, all-`Equal`, same property) so hand-built or legacy definitions that +don't match stay editable as an OR group of word-level `Equal` leaves against… +nothing — the enum editor's operator select only offers `Equal`, so such +groups seed into leaves with the property's editor anyway; a lone +`Equal v` on an enum property seeds to the same leaf with `[v]` checked. + +Consequence: the knowledge page's existing +`KnowledgeItemTypeCondition { field: 'itemType', itemTypes: [] }` maps 1:1 to +the enum leaf (its emit/fold round-trip is lossless for the +`itemTypes → OR-of-Equal` shape the page already evaluates by `includes`). + +### 7.4 Custom (valueless) operators — `isPhrase` + +`FilterCustomOperator.recognize` runs first during seed fold-back, so +`Contains ' '` on a property that declares the `isPhrase` custom op folds back +into that custom leaf (today's behavior, where `isPhrase` is stored natively +in `VocabularyCondition`). The `emit()` runs on Submit. `prepareValue` is +*skipped* for custom operators (they own their value). The recognizer must be +unambiguous — pages that also offer literal `' '` contains (none today) would +lose that distinction; documented as the hook's contract. + +## 8. UI layout (follows the vocabulary dialog) + +``` +┌───────────────────────────── title: t(titleKey) ─────────────────────────────┐ +│ ┌─ tree pane (splitLeft%) ──────┐ │ ┌─ detail pane (rest) ──────────────────┐ │ +│ │ [+cond] [+group] [delete] │ │ │ (group → join select + hint) │ │ +│ │ ─────────────────────────────│ │ │ (leaf → property select │ │ +│ │ ▾ mat-tree, always expanded │ │ │ operator select │ │ +│ │ rows: icon + label + │◄┼►│ value editor per §7) │ │ +│ │ invalid ⚠ icon │ │ │ │ │ +│ │ (draggable splitter) │ │ │ │ │ +│ └──────────────────────────────┘ │ └───────────────────────────────────────┘ │ +│ preview: