feat(search): pipeline-stage fuzzy match shortcut
Typing a stage name in the topbar search now surfaces a "Stage: <Label>"
shortcut row that lands the rep on the interests list filtered by that
stage. Previously reps had to know the navigation path and either click
through the kanban board or hand-type the URL filter.
Match flavours (case-insensitive, query tokens split on whitespace):
1. Modern label prefix — every query token must prefix a token in
`STAGE_LABELS[stage]` or the raw enum slug. "eoi" → EOI, "dep" →
Deposit Paid, "qua" → Qualified.
2. Stage-key substring on the raw enum slug.
3. Legacy aliases via `LEGACY_STAGE_REMAP` — "eoi_signed" /
"deposit_10pct" / "contract_signed" lands on the modern 7-stage
equivalent so reps with muscle memory still find a useful target.
Each row carries a live COUNT(*) of non-archived interests in that
stage (single grouped query — O(stages)). Empty queries skip the
bucket entirely.
- `searchStages(portId, query, limit)` in search.service.ts with the
scoring logic + count query.
- New `StageSuggestionResult` type added to SearchResults + the
client-side mirror in use-search.ts.
- `searchStages` wired into the parallel `Promise.all` block of the
main `search()` and the single-bucket runSingleBucket dispatch
(exhaustive ts-pattern match required the new branch).
- Gated on `interests.view` — destination of the filter.
- New 'stages' bucket in command-search.tsx BUCKETS list (between
Tags and Notes) + a `buildFlatRows` arm that pushes one row per
matched stage. Mobile overlay reuses `buildFlatRows`, so the new
rows appear there too once BUCKET_LABELS picks up the entry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -72,6 +72,11 @@ const BUCKETS: BucketConfig[] = [
|
||||
{ type: 'reminders', label: 'Reminders', icon: Bell },
|
||||
{ type: 'brochures', label: 'Brochures', icon: Camera },
|
||||
{ type: 'tags', label: 'Tags', icon: TagIcon },
|
||||
// Pipeline-stage shortcuts ("eoi sent" → /interests?pipelineStage=eoi).
|
||||
// Sits below Tags so direct entity matches always win the dropdown;
|
||||
// stage filters are macro affordances that surface when the rep types
|
||||
// a stage name verbatim.
|
||||
{ type: 'stages', label: 'Stages', icon: TrendingUp },
|
||||
// Notes are noisy content search.
|
||||
{ type: 'notes', label: 'Notes', icon: MessageSquare },
|
||||
// Navigation (settings pages + admin sub-cards) lives at the very bottom —
|
||||
@@ -1123,6 +1128,23 @@ export function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
});
|
||||
}
|
||||
}
|
||||
// Stage-filter shortcuts. Typing a stage label ("eoi sent", "deposit",
|
||||
// "qualified") surfaces a row that lands the rep on the filtered
|
||||
// interests list. count is the live row total so the rep sees how
|
||||
// many records they're about to scope to.
|
||||
if (include('stages')) {
|
||||
for (const st of results.stages) {
|
||||
rows.push({
|
||||
kind: 'result',
|
||||
key: `stages:${st.stage}`,
|
||||
bucket: 'stages',
|
||||
icon: TrendingUp,
|
||||
label: `Stage: ${st.label}`,
|
||||
sub: `${st.count} ${st.count === 1 ? 'interest' : 'interests'} in this stage`,
|
||||
href: `/${portSlug}${st.href}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Notes — content matches inside free-text notes are noisy by nature, so
|
||||
// the user sees them after the entity-specific buckets above have
|
||||
// surfaced their tighter matches.
|
||||
|
||||
@@ -548,6 +548,7 @@ const BUCKET_LABELS: Record<BucketType, string> = {
|
||||
tags: 'Tags',
|
||||
navigation: 'Settings & navigation',
|
||||
notes: 'Notes',
|
||||
stages: 'Stages',
|
||||
};
|
||||
|
||||
function Section({
|
||||
|
||||
@@ -23,7 +23,8 @@ export type BucketType =
|
||||
| 'brochures'
|
||||
| 'tags'
|
||||
| 'navigation'
|
||||
| 'notes';
|
||||
| 'notes'
|
||||
| 'stages';
|
||||
|
||||
/**
|
||||
* Provenance hint for a result row that surfaced via graph expansion
|
||||
@@ -147,6 +148,16 @@ export interface NavResult {
|
||||
label: string;
|
||||
category: 'settings' | 'admin' | 'dashboard';
|
||||
}
|
||||
export interface StageSuggestionResult {
|
||||
/** Canonical pipeline-stage value (matches PIPELINE_STAGES). */
|
||||
stage: string;
|
||||
/** Human label (STAGE_LABELS[stage]). */
|
||||
label: string;
|
||||
/** Live count of non-archived interests in this stage. */
|
||||
count: number;
|
||||
/** Slug-less href. CommandSearch prefixes the portSlug at render time. */
|
||||
href: string;
|
||||
}
|
||||
export interface OtherPortResult {
|
||||
portId: string;
|
||||
portSlug: string;
|
||||
@@ -174,6 +185,7 @@ export interface SearchResults {
|
||||
tags: TagResult[];
|
||||
navigation: NavResult[];
|
||||
notes: NoteResult[];
|
||||
stages: StageSuggestionResult[];
|
||||
totals: Record<BucketType, number>;
|
||||
otherPorts?: OtherPortResult[];
|
||||
}
|
||||
|
||||
@@ -191,6 +191,25 @@ export interface NavResult {
|
||||
category: 'settings' | 'admin' | 'dashboard';
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage-filter shortcut. Surfaced when the rep types a stage name
|
||||
* ("eoi sent", "deposit", "qualified", "reservation"). Clicking lands
|
||||
* them on /<portSlug>/interests filtered by that stage. Legacy stage
|
||||
* names (eoi_sent, contract_signed, ...) are remapped to their modern
|
||||
* 7-stage equivalents before display.
|
||||
*/
|
||||
export interface StageSuggestionResult {
|
||||
/** Canonical PIPELINE_STAGES value — drives the URL filter. */
|
||||
stage: string;
|
||||
/** Human-friendly label (STAGE_LABELS[stage]). */
|
||||
label: string;
|
||||
/** Live count of non-archived interests in this stage. */
|
||||
count: number;
|
||||
/** Pre-built link, including the portSlug placeholder so the UI
|
||||
* doesn't need to wire stage→href separately. */
|
||||
href: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Note-fragment match. Polymorphic across the four note tables
|
||||
* (client / interest / yacht / company). Each row carries enough
|
||||
@@ -226,6 +245,7 @@ export interface SearchResults {
|
||||
tags: TagResult[];
|
||||
navigation: NavResult[];
|
||||
notes: NoteResult[];
|
||||
stages: StageSuggestionResult[];
|
||||
/**
|
||||
* Total count BEFORE per-bucket cap. Lets the UI render
|
||||
* "Show 12 more clients" links into the dedicated /search page.
|
||||
@@ -1090,6 +1110,103 @@ async function searchBrochures(
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage-suggestion fuzzy match. Returns matching `PIPELINE_STAGES`
|
||||
* values when the query looks like part of a stage label.
|
||||
*
|
||||
* Three match flavours, all case-insensitive:
|
||||
* 1. Modern label tokens: query token is a prefix of any token in
|
||||
* `STAGE_LABELS[stage]` ("eoi sent" → 'eoi'; "dep" → 'deposit_paid').
|
||||
* 2. Stage key fragments: `eoi`, `dep`, `qua` against the raw enum
|
||||
* slugs.
|
||||
* 3. Legacy aliases: "eoi_sent" / "deposit_10pct" / "contract_signed"
|
||||
* remap to their modern 7-stage equivalent via `LEGACY_STAGE_REMAP`,
|
||||
* so reps with muscle memory still land somewhere useful.
|
||||
*
|
||||
* Each suggestion carries a live count of non-archived interests in that
|
||||
* stage so the rep sees how many records they're about to filter to.
|
||||
* Costs one COUNT(*) query — a single `GROUP BY` keeps it O(stages).
|
||||
*/
|
||||
async function searchStages(
|
||||
portId: string,
|
||||
query: string,
|
||||
limit: number,
|
||||
): Promise<StageSuggestionResult[]> {
|
||||
const { PIPELINE_STAGES, STAGE_LABELS, LEGACY_STAGE_REMAP } = await import('@/lib/constants');
|
||||
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return [];
|
||||
const queryTokens = normalized.split(/\s+/).filter(Boolean);
|
||||
if (queryTokens.length === 0) return [];
|
||||
|
||||
// Score each canonical stage. Higher = better match → sort descending.
|
||||
// Modern label token-prefix and stage-key prefix score above legacy
|
||||
// alias matches so canonical names sort first.
|
||||
const scored: Array<{ stage: (typeof PIPELINE_STAGES)[number]; score: number }> = [];
|
||||
for (const stage of PIPELINE_STAGES) {
|
||||
const label = STAGE_LABELS[stage].toLowerCase();
|
||||
const labelTokens = label.split(/\s+/).filter(Boolean);
|
||||
|
||||
let score = 0;
|
||||
// (1) modern label tokens — each query token must prefix some label token
|
||||
const allModernHit = queryTokens.every(
|
||||
(q) => labelTokens.some((lt) => lt.startsWith(q)) || stage.startsWith(q),
|
||||
);
|
||||
if (allModernHit) score = 100;
|
||||
|
||||
// (2) stage key fragments — substring on the raw enum slug
|
||||
if (score < 50 && stage.includes(normalized.replace(/\s+/g, '_'))) {
|
||||
score = 50;
|
||||
}
|
||||
|
||||
// (3) legacy alias prefix (e.g. "eoi_signed" → eoi)
|
||||
if (score === 0) {
|
||||
for (const [legacy, modern] of Object.entries(LEGACY_STAGE_REMAP)) {
|
||||
if (modern !== stage) continue;
|
||||
if (
|
||||
legacy.startsWith(normalized.replace(/\s+/g, '_')) ||
|
||||
legacy.replace(/_/g, ' ').startsWith(normalized)
|
||||
) {
|
||||
score = 25;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (score > 0) scored.push({ stage, score });
|
||||
}
|
||||
|
||||
if (scored.length === 0) return [];
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
const ranked = scored.slice(0, limit);
|
||||
|
||||
// Single COUNT(*) over interests grouped by pipeline_stage. Filtered
|
||||
// to the matched stages only so the planner can use the partial index
|
||||
// on (port_id, pipeline_stage) where archived_at IS NULL.
|
||||
const stageNames = ranked.map((r) => r.stage);
|
||||
const counts = await db.execute<{ pipeline_stage: string; count: string }>(sql`
|
||||
SELECT pipeline_stage, COUNT(*)::text AS count
|
||||
FROM interests
|
||||
WHERE port_id = ${portId}
|
||||
AND archived_at IS NULL
|
||||
AND pipeline_stage = ANY(${stageNames}::text[])
|
||||
GROUP BY pipeline_stage
|
||||
`);
|
||||
const countByStage = new Map<string, number>();
|
||||
for (const row of Array.from(counts)) {
|
||||
countByStage.set(row.pipeline_stage, Number(row.count) || 0);
|
||||
}
|
||||
|
||||
return ranked.map(({ stage }) => ({
|
||||
stage,
|
||||
label: STAGE_LABELS[stage],
|
||||
count: countByStage.get(stage) ?? 0,
|
||||
// Caller (CommandSearch) prefixes the portSlug — keep this slug-less
|
||||
// so the search service stays portSlug-agnostic.
|
||||
href: `/interests?pipelineStage=${stage}`,
|
||||
}));
|
||||
}
|
||||
|
||||
async function searchTags(portId: string, query: string, limit: number): Promise<TagResult[]> {
|
||||
const ilikePattern = `%${query}%`;
|
||||
|
||||
@@ -1825,6 +1942,7 @@ export async function search(
|
||||
brochures,
|
||||
tags,
|
||||
notes,
|
||||
stages,
|
||||
otherPorts,
|
||||
] = await Promise.all([
|
||||
can(opts, 'clients.view') ? searchClients(portId, query, limit) : Promise.resolve([]),
|
||||
@@ -1862,6 +1980,9 @@ export async function search(
|
||||
can(opts, 'companies.view')
|
||||
? searchNotes(portId, query, limit)
|
||||
: Promise.resolve([]),
|
||||
// Stage suggestions are gated on interests.view (the destination of
|
||||
// the filter). Skipped for users who can't see the interests list.
|
||||
can(opts, 'interests.view') ? searchStages(portId, query, limit) : Promise.resolve([]),
|
||||
opts.includeOtherPorts && opts.isSuperAdmin
|
||||
? searchOtherPorts(portId, query, limit)
|
||||
: Promise.resolve([]),
|
||||
@@ -1976,6 +2097,7 @@ export async function search(
|
||||
tags,
|
||||
navigation,
|
||||
notes,
|
||||
stages,
|
||||
totals: {
|
||||
clients: mergedClients.length,
|
||||
residentialClients: residentialClients.length,
|
||||
@@ -1993,6 +2115,7 @@ export async function search(
|
||||
tags: tags.length,
|
||||
navigation: navigation.length,
|
||||
notes: notes.length,
|
||||
stages: stages.length,
|
||||
},
|
||||
otherPorts: otherPorts.length > 0 ? otherPorts : undefined,
|
||||
};
|
||||
@@ -2161,6 +2284,16 @@ async function runSingleBucket(
|
||||
empty.totals.notes = empty.notes.length;
|
||||
return empty;
|
||||
})
|
||||
.with('stages', async () => {
|
||||
// Stage suggestions have no `id` field so applyAffinity is a no-op
|
||||
// here. The single-bucket runner still routes through it for
|
||||
// consistency with the other branches, but the recently-touched
|
||||
// boost doesn't apply.
|
||||
if (!can(opts, 'interests.view')) return empty;
|
||||
empty.stages = await searchStages(portId, query, limit);
|
||||
empty.totals.stages = empty.stages.length;
|
||||
return empty;
|
||||
})
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
@@ -2199,7 +2332,9 @@ function emptyResults(): SearchResults {
|
||||
tags: 0,
|
||||
navigation: 0,
|
||||
notes: 0,
|
||||
stages: 0,
|
||||
},
|
||||
stages: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user