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 @@ + + + + + +Tag Typeahead — Tree-Aware Results · Design Spec · Familienarchiv + + + +
+ + +
+
+
+

Tag Typeahead — Tree-Aware Results

+

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.

+
+ Design Spec +
+
+
+
API contract
+
No change — GET /api/tags still returns List<Tag>. Backend enriches the flat list.
+
+
+
Enrichment scope
+
Root match → all descendants via recursive CTE. Child match → all ancestors via recursive CTE.
+
+
+
Visual treatment
+
Direct matches: full color + font-medium. Context nodes: text-ink-3 + chevron prefix for roots.
+
+
+
Selectability
+
Every item in the list is selectable — context ancestors are valid tags, not decorations.
+
+
+
+ +
+ Scale notice: All font sizes, heights, and paddings in the mockups below are at approximately 55 % of the real implementation values. Annotations and the implementation reference tables at the bottom of each section show the real Tailwind classes and pixel values to use. +
+ + + +
+
+ 1 + Before vs. After — the two gaps this closes +
+ +
+ + +
+
Before Parent matches — children missing
+
+
familienarchiv / dokument / bearbeiten
+
+
DokumentePersonen
+
+
+
Tags
+
+
Brief
+
+ +
+
+ + Briefe + direct match +
+ +
+
+ Kinder von "Briefe" nicht sichtbar — obwohl relevant +
+
+
+
+
+
Current: only "Briefe" returned. Children like "Familienbriefe" and "Weihnachtsbriefe" are invisible unless the user already knows their exact names.
+
+ + +
+
After Parent matches — children shown below
+
+
familienarchiv / dokument / bearbeiten
+
+
DokumentePersonen
+
+
+
Tags
+
+
Brief
+
+ +
+
+ + Briefe + ↩ auswählen +
+
+ + Familienbriefe +
+
+ + Weihnachtsbriefe +
+
+
+ Kinder jetzt sichtbar und wählbar +
+
+
+
+
+
After: "Briefe" shown prominently; its children appear indented below in muted style. User can select any of them directly.
+
+ +
+ +
+ +
+ + +
+
Before Child matches — ancestor path missing
+
+
familienarchiv / dokument / bearbeiten
+
+
DokumentePersonen
+
+
+
Tags
+
+
Hochzeit
+
+ +
+
+ + Hochzeit +
+
+ + Silberhochzeit +
+
+
+ Unklar, ob "Hochzeit" das richtige Tag ist +
+
+
+
+
+
Current: results appear without hierarchy context. User cannot tell which "Hochzeit" belongs where in the tree.
+
+ + +
+
After Child matches — ancestor shown above for context
+
+
familienarchiv / dokument / bearbeiten
+
+
DokumentePersonen
+
+
+
Tags
+
+
Hochzeit
+
+ +
+
+ + + Ereignisse +
+
+ + Hochzeit + ↩ auswählen +
+
+ + Silberhochzeit +
+
+ + Goldene Hochzeit +
+
+
+ "Ereignisse" als Kontext, alle Geschwister sichtbar +
+
+
+
+
+
After: "Ereignisse" appears first as muted context (with › prefix). "Hochzeit" sits indented below — the hierarchy is immediately clear. Sibling tags are also visible for quick selection.
+
+ +
+ +
+ + + +
+
+ 2 + Multi-level path — up to 3 levels deep +
+ +
+
+
User types "gold" — match is 3 levels deep
+
+
familienarchiv / dokument / bearbeiten
+
+
DokumentePersonen
+
+
+
Tags
+
+
gold
+
+
+ +
+ + + Ereignisse +
+ +
+ + Hochzeit +
+ +
+ + Goldene Hochzeit + ↩ auswählen +
+
+
+
+
+
+
Backend returns: Goldene Hochzeit (direct match) + Hochzeit + Ereignisse (ancestors). Frontend DFS produces depth 0 → 1 → 2 ordering. Indent = depth × 16px + 12px base.
+
+ +
+
Keyboard navigation across all depths
+
+
familienarchiv / dokument / bearbeiten
+
+
DokumentePersonen
+
+
+
Tags
+
+
gold
+
+
+ +
+ + + Ereignisse + ↩ auswählen +
+
+ + Hochzeit +
+
+ + Goldene Hochzeit +
+
+
+
+
+
+
Arrow keys move through ALL items regardless of depth or match status. Pressing Enter on a context node (e.g. "Ereignisse") adds it as a tag — context nodes are fully selectable.
+
+ +
+ +
+ DFS ordering guarantee: The frontend builds a tree from the flat API response, then does a depth-first walk starting from roots (tags whose parentId is absent from the result set). This ensures parents always appear before their children, at any depth, regardless of the order the backend returns items. +
+ +
+ + + +
+
+ 3 + Mixed results — two independent subtrees in one dropdown +
+ +
+
+
User types "post" — matches across two unrelated subtrees
+
+
familienarchiv / dokument / bearbeiten
+
+
DokumentePersonen
+
+
+
Tags
+
+
post
+
+
+ +
+ + Post +
+
+ + Pakete +
+
+ + Postkarten + ↩ auswählen +
+ +
+ +
+ + + Finanzen +
+
+ + Postbank +
+
+
+
+
+
+
Two independent subtrees interleaved cleanly. DFS ensures each subtree's items are grouped together. The visual divider (1px line) between unrelated subtrees is cosmetic and optional.
+
+ +
+
Already-selected tags are filtered from suggestions
+
+
familienarchiv / dokument / bearbeiten
+
+
DokumentePersonen
+
+
+
Tags
+ +
+
+ + Post + × +
+
post
+
+
+ + +
+ + Pakete + Kind von Post +
+
+ + Postkarten + ↩ auswählen +
+
+
+ + + Finanzen +
+
+ + Postbank +
+
+
+
+
+
+
When "Post" is already selected as a chip, it's filtered from the dropdown. Its children remain visible (they're still useful), but the parent row is gone. The filter runs on tag names, same as today.
+
+
+ +
+ Context nodes of a filtered parent: If the matched direct-tag is already selected (filtered), its children can appear as "orphans" (parentId not found in result set). They should still appear — just not indented. The DFS puts them at depth 0 since their parent was removed. This is acceptable; the user sees the available children and can select them. +
+ +
+ + + +
+
+ 4 + Backend implementation reference +
+ +
+
Implementation Reference — §4 Backend + TagService.java · no new endpoint · no DTO change +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
WhatHowNotes
Filebackend/src/main/java/…/service/TagService.javaModify existing search(String query) method only. No new endpoint, no new DTO, no API type regeneration needed.
Step 1 — name searchList<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 count1 (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 typeList<Tag> — unchangedNo API contract change. Frontend Tag type ({ id, name, color?, parentId? }) already carries parentId. No generate:api run needed.
Unit tests to addTagServiceTest.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. +
+
+ +
+ + + +
+
+ 5 + Frontend implementation reference +
+ +
+
Implementation Reference — §5 TagInput.svelte + frontend/src/lib/components/TagInput.svelte +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
WhatHowNotes
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 classDelete 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 indentationdepth 0 → 12px · depth 1 → 28px · depth 2 → 44pxFormula: depth * 16 + 12. Maximum realistic depth in this app is 3–4 levels. No truncation needed.
Context node styletext-ink-3 (#6b7280) · no font-weight overrideContext 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 keysNone new required.The feature is purely visual. All existing comp_taginput_* keys remain applicable.
Frontend tests to updateTagInput.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. +
+
+ +
+ +
+ +