Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 24 additions & 10 deletions src/lib/Search.fns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import type { Result } from './Search'

type DbResult = Omit<Result, 'pr'> & { pr_json: string }

/** Search TIPs with FTS5, supporting exact number shortcuts and prefix matching. */
export const query = createServerFn({ method: 'POST' })
.inputValidator(z.string())
Expand All @@ -18,9 +20,15 @@ export const query = createServerFn({ method: 'POST' })
const numMatch = trimmed.match(/^(?:tip-?)?(\d+)$/i)
if (numMatch) {
const row = await db
.prepare('SELECT number, title, authors, status FROM tips WHERE number = ?')
.prepare('SELECT number, title, authors, status, pr_json FROM tips WHERE number = ?')
.bind(numMatch[1])
.first<{ number: string; title: string; authors: string; status: string }>()
.first<{
number: string
title: string
authors: string
status: string
pr_json: string
}>()
if (row)
return [
{
Expand All @@ -30,6 +38,7 @@ export const query = createServerFn({ method: 'POST' })
status: row.status,
snippet: '',
rank: 0,
pr: row.pr_json ? JSON.parse(row.pr_json) : undefined,
},
] as Result[]
}
Expand All @@ -46,7 +55,7 @@ export const query = createServerFn({ method: 'POST' })
const [titleRows, allRows] = await Promise.all([
db
.prepare(
`SELECT t.number, t.title, t.authors, t.status,
`SELECT t.number, t.title, t.authors, t.status, t.pr_json,
snippet(tips_fts, 1, '<mark>', '</mark>', '…', 32) as snippet,
bm25(tips_fts, 10, 50, 3, 5, 1) as rank
FROM tips_fts
Expand All @@ -56,10 +65,10 @@ export const query = createServerFn({ method: 'POST' })
LIMIT 10`,
)
.bind(titleQuery)
.all<Result>(),
.all<DbResult>(),
db
.prepare(
`SELECT t.number, t.title, t.authors, t.status,
`SELECT t.number, t.title, t.authors, t.status, t.pr_json,
COALESCE(
NULLIF(snippet(tips_fts, 4, '<mark>', '</mark>', '…', 32), ''),
NULLIF(snippet(tips_fts, 3, '<mark>', '</mark>', '…', 32), ''),
Expand All @@ -73,13 +82,18 @@ export const query = createServerFn({ method: 'POST' })
LIMIT 20`,
)
.bind(ftsQuery)
.all<Result>(),
.all<DbResult>(),
])

// Merge: title matches first, then remaining general matches
const seen = new Set(titleRows.results.map((r) => r.number))
return [
...titleRows.results,
...allRows.results.filter((r) => !seen.has(r.number)),
].slice(0, 20)
return [...titleRows.results, ...allRows.results.filter((r) => !seen.has(r.number))]
.slice(0, 20)
.map((r) => {
const { pr_json, ...result } = r
return {
...result,
pr: pr_json ? JSON.parse(pr_json) : undefined,
}
})
})
3 changes: 3 additions & 0 deletions src/lib/Search.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
/** Search result from FTS5 query. */
import type { PrInfo } from './Tips'

export type Result = {
number: string
title: string
authors: string
status: string
snippet: string
rank: number
pr?: PrInfo | undefined
}
168 changes: 110 additions & 58 deletions src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as Tips from '#/lib/Tips.fns'
import * as Search from '#/lib/Search.fns'
import * as Config from '#/lib/Config'
import type * as SearchTypes from '#/lib/Search'
import type * as TipTypes from '#/lib/Tips'

export const Route = createFileRoute('/')({
loader: () => Tips.list(),
Expand Down Expand Up @@ -84,6 +85,38 @@ function StatusBadge({ status, isPr }: { status: string; isPr?: boolean }) {
)
}

function displayStatus(tip: Pick<TipTypes.Summary, 'status' | 'pr'>) {
return tip.pr ? 'Proposed' : tip.status
}

function statusKey(status: string) {
return status.toLowerCase().replace(/\s+/g, '-')
}

function StatusFilter({
value,
statuses,
onChange,
}: {
value: string
statuses: string[]
onChange: (v: string) => void
}) {
return (
<label className="tip-status-filter">
<span>Status</span>
<select value={value} onChange={(e) => onChange(e.target.value)}>
<option value="">All</option>
{statuses.map((status) => (
<option key={statusKey(status)} value={statusKey(status)}>
{status}
</option>
))}
</select>
</label>
)
}

function SearchBox({
value,
onChange,
Expand Down Expand Up @@ -148,7 +181,13 @@ function SearchBox({
)
}

function SearchResults({ results, activeIndex }: { results: SearchTypes.Result[]; activeIndex: number }) {
function SearchResults({
results,
activeIndex,
}: {
results: SearchTypes.Result[]
activeIndex: number
}) {
if (results.length === 0)
return <p style={{ color: 'var(--color-text-muted)', marginTop: '1.5em' }}>No results found.</p>

Expand Down Expand Up @@ -196,7 +235,7 @@ function SearchResults({ results, activeIndex }: { results: SearchTypes.Result[]
</span>
<span style={{ fontWeight: 700, minWidth: 0 }}>{r.title}</span>
<span style={{ marginLeft: 'auto', flexShrink: 0 }}>
<StatusBadge status={r.status} />
<StatusBadge status={r.status} isPr={!!r.pr} />
</span>
</div>
{r.snippet && (
Expand All @@ -220,13 +259,23 @@ function SearchResults({ results, activeIndex }: { results: SearchTypes.Result[]
function TipsIndex() {
const tips = Route.useLoaderData()
const [query, setQuery] = useQueryState('q', parseAsString.withDefault(''))
const [status, setStatus] = useQueryState('status', parseAsString.withDefault(''))
const [results, setResults] = useState<SearchTypes.Result[] | null>(null)
const [searching, setSearching] = useState(false)
const [activeIndex, setActiveIndex] = useState(-1)
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null)
const navigate = Route.useNavigate()

const isSearching = query.length > 0
const statuses = Array.from(new Set(tips.map(displayStatus))).sort((a, b) => a.localeCompare(b))
const visibleTips = status ? tips.filter((tip) => statusKey(displayStatus(tip)) === status) : tips
const visibleResults = status
? (results?.filter((result) => statusKey(displayStatus(result)) === status) ?? null)
: results

useEffect(() => {
setActiveIndex(-1)
}, [status])

useEffect(() => {
if (!query.trim()) {
Expand Down Expand Up @@ -264,28 +313,31 @@ function TipsIndex() {
</div>

<div className="tip-body">
<SearchBox
value={query}
onChange={setQuery}
onArrow={(dir) => {
if (!results?.length) return
setActiveIndex((prev) => {
if (dir === 'down') return prev < results.length - 1 ? prev + 1 : 0
return prev > 0 ? prev - 1 : results.length - 1
})
}}
onCommit={() => {
if (results && activeIndex >= 0 && activeIndex < results.length) {
navigate({
to: '/$tipId',
params: { tipId: results[activeIndex].number },
<div className="tip-index-controls">
<SearchBox
value={query}
onChange={setQuery}
onArrow={(dir) => {
if (!visibleResults?.length) return
setActiveIndex((prev) => {
if (dir === 'down') return prev < visibleResults.length - 1 ? prev + 1 : 0
return prev > 0 ? prev - 1 : visibleResults.length - 1
})
}
}}
/>
}}
onCommit={() => {
if (visibleResults && activeIndex >= 0 && activeIndex < visibleResults.length) {
navigate({
to: '/$tipId',
params: { tipId: visibleResults[activeIndex].number },
})
}
}}
/>
<StatusFilter value={status} statuses={statuses} onChange={setStatus} />
</div>

{isSearching ? (
searching && !results ? (
searching && !visibleResults ? (
<p
style={{
color: 'var(--color-text-muted)',
Expand All @@ -295,47 +347,47 @@ function TipsIndex() {
Searching…
</p>
) : (
results && <SearchResults results={results} activeIndex={activeIndex} />
visibleResults && <SearchResults results={visibleResults} activeIndex={activeIndex} />
)
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ marginTop: '0.5em' }}>
<caption className="sr-only">List of Tempo Improvement Proposals</caption>
<thead>
<tr>
<th style={{ width: '5rem' }}>Number</th>
<th>Title</th>
<th style={{ width: '7rem', textAlign: 'right' }}>Status</th>
</tr>
</thead>
<tbody>
{tips.map((tip) => (
<tr key={tip.number}>
<td>
<Link
to="/$tipId"
params={{ tipId: tip.number }}
style={{ fontFamily: 'var(--font-serif)' }}
>
<TipNumber value={tip.number} prUrl={tip.pr?.url} />
</Link>
</td>
<td>
<Link
to="/$tipId"
params={{ tipId: tip.number }}
style={{ textDecoration: 'none', color: 'inherit' }}
>
{tip.title}
</Link>
</td>
<td style={{ textAlign: 'right' }}>
<StatusBadge status={tip.status} isPr={!!tip.pr} />
</td>
<table style={{ marginTop: '0.5em' }}>
<caption className="sr-only">List of Tempo Improvement Proposals</caption>
<thead>
<tr>
<th style={{ width: '5rem' }}>Number</th>
<th>Title</th>
<th style={{ width: '7rem', textAlign: 'right' }}>Status</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{visibleTips.map((tip) => (
<tr key={tip.number}>
<td>
<Link
to="/$tipId"
params={{ tipId: tip.number }}
style={{ fontFamily: 'var(--font-serif)' }}
>
<TipNumber value={tip.number} prUrl={tip.pr?.url} />
</Link>
</td>
<td>
<Link
to="/$tipId"
params={{ tipId: tip.number }}
style={{ textDecoration: 'none', color: 'inherit' }}
>
{tip.title}
</Link>
</td>
<td style={{ textAlign: 'right' }}>
<StatusBadge status={tip.status} isPr={!!tip.pr} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
Expand Down
30 changes: 29 additions & 1 deletion src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,9 @@ figcaption {
text-decoration: none;
border-left: 1.5px solid transparent;
margin-left: -0.75px;
transition: color 0.12s ease, border-color 0.12s ease;
transition:
color 0.12s ease,
border-color 0.12s ease;
}

.tip-toc-item a:hover {
Expand Down Expand Up @@ -434,6 +436,32 @@ figcaption {
margin-top: 0;
}

/* ── TIP Index Controls ── */
.tip-index-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6em 0.8em;
}

.tip-status-filter {
display: inline-flex;
align-items: center;
gap: 0.45em;
color: var(--color-text-muted);
font-size: 0.92em;
}

.tip-status-filter select {
font-family: var(--font-serif);
font-size: 1em;
padding: 0.2em 0.6em;
border: 1px solid var(--color-rule-light);
border-radius: 3px;
background: var(--color-bg);
color: var(--color-text);
}

/* ── Heading anchor links ── */
.heading-anchor {
scroll-margin-top: 1em;
Expand Down