feat(ui): replace title-only filter with full-text search (T8.2, FR-SEARCH-1)

The search box now searches transcripts + notes (not just titles) and
shows a matched snippet per result. FTS5 is word-based rather than
substring-based, a deliberate tradeoff for gaining full-content search.
This commit is contained in:
iamdoubz
2026-07-02 10:08:12 -05:00
parent 3d1129ff1e
commit 66d4c614bb
+32 -7
View File
@@ -1,15 +1,24 @@
<script lang="ts">
// Left pane: meetings list + search/filter (Phase 2, FR-STORE-5; Phase 8 search).
// Left pane: meetings list + full-text search (Phase 2, FR-STORE-5; T8.2, FR-SEARCH-1).
import { meetings } from "../stores/meetings.svelte";
import { recording } from "../stores/recording.svelte";
import type { MeetingListItem } from "../api";
import type { MeetingListItem, SearchHit } from "../api";
let query = $state("");
let searchTimer: ReturnType<typeof setTimeout> | undefined;
function onSearchInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => meetings.load(query || undefined), 250);
searchTimer = setTimeout(() => meetings.search(query), 250);
}
// Unified view: full-text results (with snippets) while searching, else the
// plain list. Same underlying fields either way.
let displayItems = $derived<(MeetingListItem | SearchHit)[]>(
meetings.searchResults ?? meetings.list,
);
function snippetOf(item: MeetingListItem | SearchHit): string | null {
return "snippet" in item ? item.snippet : null;
}
function formatDate(unixSecs: number): string {
@@ -28,7 +37,7 @@
return `${m}:${s.toString().padStart(2, "0")}`;
}
async function openMeeting(m: MeetingListItem) {
async function openMeeting(m: MeetingListItem | SearchHit) {
if (recording.state !== "idle" && recording.meetingId === m.id) return; // still live
await meetings.select(m.id);
}
@@ -52,13 +61,17 @@
bind:value={query}
oninput={onSearchInput}
/>
{#if meetings.list.length === 0 && meetings.loading}
{#if meetings.searching}
<p class="muted">Searching…</p>
{:else if displayItems.length === 0 && meetings.loading}
<p class="muted">Loading meetings…</p>
{:else if meetings.list.length === 0}
{:else if displayItems.length === 0 && meetings.searchResults !== null}
<p class="muted">No matches.</p>
{:else if displayItems.length === 0}
<p class="muted">No meetings yet. Click ● Record to start.</p>
{:else}
<ul>
{#each meetings.list as m (m.id)}
{#each displayItems as m (m.id)}
<li class:active={meetings.selectedId === m.id}>
<button class="item-btn" onclick={() => openMeeting(m)}>
<span class="row">
@@ -75,6 +88,9 @@
<span>{formatDate(m.started_at)}</span>
{#if m.duration_secs}<span>{formatDuration(m.duration_secs)}</span>{/if}
</span>
{#if snippetOf(m)}
<span class="snippet">{snippetOf(m)}</span>
{/if}
</button>
<div class="row actions">
{#if m.status === "recovering"}
@@ -113,6 +129,15 @@
.small {
font-size: 0.75rem;
}
.snippet {
display: block;
color: var(--muted);
font-size: 0.78rem;
margin-top: 0.15rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
ul {
list-style: none;
padding: 0;