Skip to content

Instantly share code, notes, and snippets.

@PatrickJS
Last active June 18, 2026 20:03
Show Gist options
  • Select an option

  • Save PatrickJS/cfc5c73429791c53f523b85b2ccf79c8 to your computer and use it in GitHub Desktop.

Select an option

Save PatrickJS/cfc5c73429791c53f523b85b2ccf79c8 to your computer and use it in GitHub Desktop.
Generic intent-aware dashboard sidebar grouping and filtering

Intent-aware dashboard sidebar grouping and filtering

Use this pattern when a dashboard sidebar has many actions, commands, reports, or workflows, and users are more likely to search by goal than by exact feature name.

The core idea is simple:

  1. Group sidebar items by user intent, not implementation area.
  2. Add lightweight intent metadata to each group and item.
  3. Normalize the user's search phrase.
  4. Score item matches using exact phrases, meaningful terms, and optional domain/catalog matches.
  5. Hide weak incidental matches when one or more strong matches exist.

This does not require an LLM. A curated synonym map plus a small scoring function is usually enough for dashboard navigation.

1. Start from user goals

Before writing the algorithm, collect the language users already see or use:

  • Product docs and onboarding guides
  • Existing sidebar labels
  • Button labels and empty states
  • Command descriptions or API operation descriptions
  • Support docs and troubleshooting pages
  • Search logs, if available
  • Repeated user tasks from issue titles, tickets, or internal runbooks

Look for verbs and outcomes, not nouns alone.

Good intent phrases:

  • set up provider
  • connect account
  • add record
  • delete old item
  • find an option
  • troubleshoot setup
  • export report
  • review usage
  • invite teammate

Weak generic terms:

  • dashboard
  • item
  • model
  • record
  • data
  • settings
  • local
  • current

Generic nouns often appear everywhere, so they should usually be low-signal or ignored unless paired with a stronger verb.

2. Define workflow groups

Workflow groups should match the user's mental model. A good default order is:

  1. Start / setup
  2. Create / add / connect
  3. Browse / inspect / find
  4. Decide / recommend / compare
  5. Maintain / refresh / monitor
  6. Export / hand off / share
  7. Remove / archive / clean up

Example:

const workflowGroups = [
  {
    id: "start",
    title: "Start & connect",
    description: "Bring the system online and confirm it works.",
    itemIds: ["setup", "connect", "doctor"],
    intent: "start setup install connect configure verify doctor health troubleshoot"
  },
  {
    id: "manage",
    title: "Manage items",
    description: "Add, inspect, update, or remove saved items.",
    itemIds: ["add", "list", "update", "remove"],
    intent: "manage add create list inspect inventory update refresh remove delete clean up"
  },
  {
    id: "discover",
    title: "Discover & choose",
    description: "Find options and pick the right one for a task.",
    itemIds: ["browse", "search", "recommend"],
    intent: "discover browse search find choose pick recommend compare best option"
  },
  {
    id: "handoff",
    title: "Report & hand off",
    description: "Review activity, export results, or prepare next steps.",
    itemIds: ["stats", "export", "handoff"],
    intent: "report stats usage history export share handoff prepare next steps"
  }
];

3. Define sidebar items

Keep the normal product title and description. Add dashboard-only intent terms separately so you do not have to rename commands, routes, or API operations just to improve navigation.

const sidebarItems = [
  {
    id: "setup",
    title: "Set up provider",
    description: "Install the provider and run the first connection check.",
    sort: 10,
    intent: "first time setup install start configure provider get started"
  },
  {
    id: "list",
    title: "Installed items",
    description: "See what is already available in this workspace.",
    sort: 30,
    intent: "installed list inventory what do i have already available current workspace"
  },
  {
    id: "remove",
    title: "Remove item",
    description: "Delete an item you no longer need.",
    sort: 90,
    intent: "remove delete uninstall archive clean up cleanup free space discard"
  },
  {
    id: "doctor",
    title: "Check setup",
    description: "Diagnose configuration and connection problems.",
    sort: 100,
    intent: "check setup doctor health verify troubleshoot broken diagnose connection problem"
  }
];

4. Optionally bootstrap intent terms from docs

A docs scan should create suggestions, not silently replace hand-curated metadata. It is useful for finding verbs and phrases you forgot.

const commonIntentVerbs = [
  "set up",
  "install",
  "connect",
  "configure",
  "add",
  "create",
  "import",
  "browse",
  "search",
  "find",
  "choose",
  "recommend",
  "compare",
  "update",
  "refresh",
  "sync",
  "check",
  "verify",
  "troubleshoot",
  "diagnose",
  "export",
  "share",
  "handoff",
  "remove",
  "delete",
  "archive"
];

function suggestIntentTermsFromDocs(markdown) {
  const text = normalizeSearchText(markdown);
  const sentences = text.split(/[.!?
]+/).map((line) => line.trim()).filter(Boolean);
  const suggestions = new Map();

  for (const verb of commonIntentVerbs) {
    const related = sentences
      .filter((sentence) => sentence.includes(verb))
      .slice(0, 5);

    if (related.length > 0) {
      suggestions.set(verb, [...new Set(related.flatMap(searchTerms))].slice(0, 20));
    }
  }

  return suggestions;
}

Ways to use the suggestions:

  • Add missing synonyms to item intent fields.
  • Add missing workflow group intent words.
  • Identify generic domain nouns that should become stop words.
  • Confirm that sidebar group names match docs language.

5. Score item matches

This scoring model prefers exact goal phrases first, then useful terms, then optional domain/catalog matches.

function filterSidebarGroups(input, items, groups, options = {}) {
  const query = normalizeSearchText(input);
  const terms = searchTerms(query, options.stopWords);

  const scored = items.map((item) => ({
    item,
    score: scoreSidebarItem(item, query, terms, options)
  }));

  const strongestScore = Math.max(0, ...scored.map((entry) => entry.score));
  const scoreFloor = operationScoreFloor(query, terms, strongestScore);
  const matching = scored.filter((entry) => !query || entry.score >= scoreFloor);

  return groups
    .map((group) => {
      const allowed = new Set(group.itemIds);
      let groupItems = matching
        .filter(({ item }) => allowed.has(item.id))
        .sort((left, right) => {
          if (query && right.score !== left.score) return right.score - left.score;
          return itemSort(left.item) - itemSort(right.item) || left.item.title.localeCompare(right.item.title);
        })
        .map(({ item }) => item);

      // If the query clearly matches the group but no specific item matched,
      // show the whole group. This helps queries like "admin setup" or
      // "reporting" land somewhere useful.
      if (query && groupItems.length === 0 && scoreWorkflowGroup(group, query, terms) > 0) {
        groupItems = items
          .filter((item) => allowed.has(item.id))
          .sort((left, right) => itemSort(left) - itemSort(right));
      }

      return {
        ...group,
        items: groupItems
      };
    })
    .filter((group) => group.items.length > 0);
}

function scoreSidebarItem(item, query, terms, options = {}) {
  if (!query) return 1;

  const titleText = normalizeSearchText(item.title);
  const descriptionText = normalizeSearchText(item.description);
  const intentText = normalizeSearchText(item.intent);
  const extraText = normalizeSearchText([
    item.id,
    item.category,
    item.tags?.join(" "),
    options.extraTextByItemId?.[item.id]
  ].filter(Boolean).join(" "));

  const allText = [titleText, descriptionText, intentText, extraText].join(" ");
  let score = 0;

  // Exact multi-word intent phrases are strong.
  if (query.includes(" ") && intentText.includes(query)) score += 40;
  if (query.includes(" ") && titleText.includes(query)) score += 30;
  if (query.includes(" ") && allText.includes(query)) score += 20;

  for (const term of terms) {
    if (intentText.includes(term)) score += 8;
    if (titleText.includes(term)) score += 6;
    if (descriptionText.includes(term)) score += 3;
    if (extraText.includes(term)) score += 2;
  }

  // Optional: connect item search to a domain catalog, entity index, docs index,
  // route list, etc. Keep the boost modest so it does not overwhelm exact intent.
  if (options.domainText && domainIntentScore(query, terms, options.domainText) > 0) {
    if (options.domainItemIds?.includes(item.id)) score += domainIntentScore(query, terms, options.domainText);
  }

  return score;
}

function scoreWorkflowGroup(group, query, terms) {
  const text = normalizeSearchText([
    group.title,
    group.description,
    group.intent
  ].filter(Boolean).join(" "));

  if (query.includes(" ") && text.includes(query)) return 20;
  return terms.reduce((score, term) => text.includes(term) ? score + 4 : score, 0);
}

function operationScoreFloor(query, terms, strongestScore) {
  if (!query) return 0;

  // If something matched a full phrase, suppress weak incidental term matches.
  if (query.includes(" ") && strongestScore >= 20) return 20;

  // If several useful terms matched, require more than one weak hit.
  if (terms.length > 0 && strongestScore >= 8) return 8;

  return 1;
}

function domainIntentScore(query, terms, domainText) {
  const text = normalizeSearchText(domainText);
  if (query.includes(" ") && text.includes(query)) return 8;
  return terms.some((term) => text.includes(term)) ? 4 : 0;
}

function itemSort(item) {
  const sort = Number(item.sort);
  return Number.isFinite(sort) ? sort : Number.POSITIVE_INFINITY;
}

6. Normalize and drop low-signal words

Use a global stop list plus a dashboard-specific stop list. The dashboard-specific list matters most.

const genericStopWords = new Set([
  "a",
  "an",
  "and",
  "are",
  "can",
  "did",
  "for",
  "get",
  "has",
  "have",
  "how",
  "i",
  "me",
  "my",
  "need",
  "the",
  "to",
  "want",
  "what",
  "with"
]);

const dashboardStopWords = new Set([
  // Customize these for your product. These are often too broad in dashboards.
  "dashboard",
  "item",
  "items",
  "record",
  "records",
  "data",
  "local",
  "current",
  "settings"
]);

function normalizeSearchText(value) {
  return String(value || "")
    .toLowerCase()
    .replace(/[^a-z0-9.+:-]+/g, " ")
    .trim();
}

function searchTerms(query, extraStopWords = new Set()) {
  const stopWords = new Set([
    ...genericStopWords,
    ...dashboardStopWords,
    ...extraStopWords
  ]);

  return normalizeSearchText(query)
    .split(/\s+/)
    .filter((term) => term.length > 2 && !stopWords.has(term));
}

7. Example behavior

Given the sample items above:

filterSidebarGroups("what do I have installed", sidebarItems, workflowGroups)
// Manage items -> Installed items

filterSidebarGroups("delete old item and free space", sidebarItems, workflowGroups)
// Manage items -> Remove item

filterSidebarGroups("troubleshoot connection", sidebarItems, workflowGroups)
// Start & connect -> Check setup

filterSidebarGroups("find best option", sidebarItems, workflowGroups)
// Discover & choose -> Browse/Search/Recommend, depending on metadata

8. Practical tuning loop

  1. Start with group and item metadata from docs.
  2. Add 10-20 real user phrases you expect people to type.
  3. Run those phrases against the scorer.
  4. If results are too broad, add stop words or raise the score floor.
  5. If results miss obvious actions, add exact phrases to the item intent metadata.
  6. If a phrase should show an entire area, add it to the workflow group intent metadata.
  7. Keep CLI/API/route names stable; tune dashboard-only metadata instead.

9. Why this works

Most dashboard searches are not open-ended semantic search. They are short goal statements like:

  • "set this up"
  • "what do I have"
  • "delete old stuff"
  • "why is this broken"
  • "find the best option"

For that shape of problem, deterministic metadata and scoring are easier to debug than embeddings or an LLM. You can inspect exactly why an item matched, tune terms in source control, and keep behavior stable for users.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment