diff --git a/docs/specs/tag-typeahead-tree-aware.html b/docs/specs/tag-typeahead-tree-aware.html new file mode 100644 index 00000000..203ca723 --- /dev/null +++ b/docs/specs/tag-typeahead-tree-aware.html @@ -0,0 +1,760 @@ + + +
+ + +When a parent tag matches a search query, its children appear below it. When a child tag matches, its ancestor chain appears above it as navigational context. All returned items remain selectable.
+| What | How | Notes |
|---|---|---|
| File | +backend/src/main/java/…/service/TagService.java |
+ Modify existing search(String query) method only. No new endpoint, no new DTO, no API type regeneration needed. |
+
| Step 1 — name search | +List<Tag> matched = tagRepository.findByNameContainingIgnoreCase(query); |
+ Existing query, unchanged. Returns early if empty. | +
| Step 2 — collect extra IDs | +Set<UUID> extraIds = new HashSet<>();
+for (Tag t : matched) {
+ if (t.getParentId() == null) {
+ // root match → expand descendants
+ extraIds.addAll(tagRepository.findDescendantIds(t.getId()));
+ } else {
+ // child match → walk up to root
+ extraIds.addAll(tagRepository.findAncestorIds(t.getId()));
+ }
+}
+// remove IDs we already have to avoid duplicate fetch
+Set<UUID> matchedIds = matched.stream()
+ .map(Tag::getId).collect(Collectors.toSet());
+extraIds.removeAll(matchedIds); |
+ findDescendantIds and findAncestorIds are recursive CTEs already in TagRepository, depth-guarded at 50 levels. Both return List<UUID> including the seed ID — removing matched IDs deduplicates cleanly. |
+
| Step 3 — batch fetch & merge | +List<Tag> all = new ArrayList<>(matched);
+if (!extraIds.isEmpty()) {
+ all.addAll(tagRepository.findAllById(extraIds));
+}
+resolveEffectiveColors(all);
+return all; |
+ tagRepository.findAllById() is inherited from JpaRepository — one IN-clause query. resolveEffectiveColors() already exists in TagService; it populates inherited color from parent for child tags. |
+
| Query count | +1 (name search) + N (CTE per matched tag) + 1 (batch fetch) | +For a typical query matching 1–3 tags: 3–5 queries total. CTEs are recursive but depth-guarded; negligible cost for typical tag trees of depth ≤ 4. | +
| Return type | +List<Tag> — unchanged |
+ No API contract change. Frontend Tag type ({ id, name, color?, parentId? }) already carries parentId. No generate:api run needed. |
+
| Unit tests to add | +TagServiceTest.java |
+
+ 1. search_includes_children_when_root_matches() — mock root match, mock findDescendantIds, assert children in result.+ 2. search_includes_ancestors_when_child_matches() — mock child match with parentId, mock findAncestorIds, assert parent in result.+ 3. search_deduplicates_when_ancestor_also_matches() — parent matches AND child matches; ancestor not duplicated in result.
+ |
+
| What | How | Notes |
|---|---|---|
| New type alias | +type SuggestionEntry = {
+ tag: Tag;
+ depth: number;
+ isDirectMatch: boolean;
+}; |
+ Replace the current Tag[] return type of orderedSuggestions with SuggestionEntry[]. The Tag type is already imported from the local definition at the top of the file. |
+
| Direct match set | +const directMatchIds = $derived( + new Set( + suggestions + .filter(s => s.id && + s.name.toLowerCase() + .includes(inputVal.toLowerCase())) + .map(s => s.id!) + ) +); |
+ A tag is a "direct match" if its name contains the current input text (case-insensitive). Tags added by backend enrichment (ancestors/descendants) will not match, so isDirectMatch will be false for them. |
+
| Replace orderedSuggestions | +const orderedSuggestions = $derived.by((): SuggestionEntry[] => {
+ const byId = new Map(
+ suggestions.filter(s => s.id).map(s => [s.id!, s])
+ );
+ const childrenOf = new Map<string, Tag[]>();
+ for (const s of suggestions) {
+ if (s.parentId && byId.has(s.parentId)) {
+ const list = childrenOf.get(s.parentId) ?? [];
+ list.push(s);
+ childrenOf.set(s.parentId, list);
+ }
+ }
+ // roots = tags whose parent is not in this result set
+ const roots = suggestions.filter(
+ s => !s.parentId || !byId.has(s.parentId)
+ );
+ const result: SuggestionEntry[] = [];
+
+ function walk(tag: Tag, depth: number) {
+ result.push({
+ tag,
+ depth,
+ isDirectMatch: directMatchIds.has(tag.id!)
+ });
+ for (const child of childrenOf.get(tag.id!) ?? []) {
+ walk(child, depth + 1);
+ }
+ }
+ for (const root of roots) walk(root, 0);
+ return result;
+}); |
+ Replaces the existing 3-bucket (roots/childrenMap/orphans) logic. The DFS handles unlimited depth. The "orphan" concept disappears — items whose parent is absent become roots at depth 0. | +
| Update handleKeydown | +// Before: addTag(orderedSuggestions[activeIndex]) +// After: +addTag(orderedSuggestions[activeIndex].tag) |
+ The activeIndex logic itself is unchanged. Only the access pattern changes: .tag to unwrap the SuggestionEntry. |
+
| Dropdown template item | +{#each orderedSuggestions as { tag: s, depth, isDirectMatch }, i (s.id ?? s.name)}
+ <li
+ role="option"
+ aria-selected={i === activeIndex}
+ tabindex="0"
+ style="padding-left: {depth * 16 + 12}px"
+ class="cursor-pointer py-2 pr-3 text-sm border-b border-line-2
+ last:border-b-0
+ {i === activeIndex
+ ? 'bg-muted font-bold text-ink'
+ : isDirectMatch
+ ? 'text-ink font-medium'
+ : 'text-ink-3'}
+ hover:bg-muted"
+ onclick={() => addTag(s)}
+ onkeydown={(e) => e.key === 'Enter' && addTag(s)}
+ >
+ {#if !isDirectMatch && depth === 0}
+ <span class="mr-1 text-ink-3">›</span>
+ {/if}
+ {#if s.color}
+ <span
+ style="background-color: var(--c-tag-{s.color})"
+ class="inline-block h-2 w-2 rounded-full mr-1 opacity-{isDirectMatch ? '100' : '50'}"
+ ></span>
+ {/if}
+ {s.name}
+ </li>
+{/each} |
+
+ Indent formula: depth × 16 + 12 px (inline style — Tailwind dynamic classes not viable for variable values).+ Context nodes at depth 0 (ancestors without a parent in results) get a › prefix to signal "this is context, not a primary result".+ Color dot opacity: 100% for direct matches, 50% for context nodes. + |
+
| Remove pl-6 class | +Delete the old conditional {suggestion.parentId && suggestionsById.has(suggestion.parentId) ? 'pl-6' : ''} |
+ Indentation is now handled entirely by the style="padding-left: …" inline style based on computed depth. The old suggestionsById derived map can also be removed as it's no longer needed. |
+
| Depth indentation | +depth 0 → 12px · depth 1 → 28px · depth 2 → 44px | +Formula: depth * 16 + 12. Maximum realistic depth in this app is 3–4 levels. No truncation needed. |
+
| Context node style | +text-ink-3 (#6b7280) · no font-weight override |
+ Context nodes (not a direct match) use the muted gray. When hovered or keyboard-active, they get bg-muted font-bold text-ink — same as any other item. They are fully clickable. |
+
| i18n keys | +None new required. | +The feature is purely visual. All existing comp_taginput_* keys remain applicable. |
+
| Frontend tests to update | +TagInput.svelte.spec.ts |
+
+ 1. Update existing "shows child after parent" test — it now expects SuggestionEntry depth/match info.+ 2. Add: ancestor shown above direct match with text-ink-3.+ 3. Add: 3-level path renders in correct DFS order. + 4. Add: keyboard Enter on context node adds it as a tag. + |
+