@cavuno/board 1.37.0 → 1.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,144 @@
1
+ // src/suggest/index.ts
2
+ var DEFAULT_MIN_CHARS = 2;
3
+ var DEFAULT_DEBOUNCE_MS = 250;
4
+ function isAbortError(error) {
5
+ return typeof DOMException !== "undefined" && error instanceof DOMException && error.name === "AbortError" || error instanceof Error && error.name === "AbortError";
6
+ }
7
+ function applyExclusions(items, excluded) {
8
+ if (excluded.size === 0) return items.slice();
9
+ return items.filter(
10
+ (item) => !(item.type === "company" && excluded.has(item.slug.toLowerCase()))
11
+ );
12
+ }
13
+ function createSuggestController(board, options) {
14
+ const minChars = options?.minChars ?? DEFAULT_MIN_CHARS;
15
+ const debounceMs = options?.debounceMs ?? DEFAULT_DEBOUNCE_MS;
16
+ const limit = options?.limit;
17
+ let disposed = false;
18
+ let state = {
19
+ query: "",
20
+ items: [],
21
+ status: "idle",
22
+ error: null
23
+ };
24
+ let rawItems = [];
25
+ let excluded = /* @__PURE__ */ new Set();
26
+ let sequence = 0;
27
+ let timer = null;
28
+ let abortController = null;
29
+ const listeners = /* @__PURE__ */ new Set();
30
+ function notify() {
31
+ for (const listener of listeners) listener();
32
+ }
33
+ function setState(next) {
34
+ state = next;
35
+ notify();
36
+ }
37
+ function cancelTimer() {
38
+ if (timer !== null) {
39
+ globalThis.clearTimeout(timer);
40
+ timer = null;
41
+ }
42
+ }
43
+ function abortInFlight() {
44
+ if (abortController) {
45
+ abortController.abort();
46
+ abortController = null;
47
+ }
48
+ }
49
+ function runRequest(query) {
50
+ const seq = ++sequence;
51
+ abortInFlight();
52
+ const controller = new AbortController();
53
+ abortController = controller;
54
+ const suggestQuery = { q: query };
55
+ if (limit !== void 0) suggestQuery.limit = limit;
56
+ void board.search.suggest(suggestQuery, { signal: controller.signal }).then((result) => {
57
+ if (disposed || seq !== sequence) return;
58
+ abortController = null;
59
+ rawItems = result.items;
60
+ setState({
61
+ query: state.query,
62
+ items: applyExclusions(rawItems, excluded),
63
+ status: "ready",
64
+ error: null
65
+ });
66
+ }).catch((error) => {
67
+ if (disposed || seq !== sequence) return;
68
+ if (isAbortError(error)) return;
69
+ abortController = null;
70
+ setState({
71
+ query: state.query,
72
+ items: state.items,
73
+ status: "error",
74
+ error
75
+ });
76
+ });
77
+ }
78
+ return {
79
+ setQuery(query) {
80
+ if (disposed) return;
81
+ const trimmed = query.trim();
82
+ cancelTimer();
83
+ if (trimmed.length < minChars) {
84
+ sequence += 1;
85
+ abortInFlight();
86
+ rawItems = [];
87
+ setState({
88
+ query,
89
+ items: [],
90
+ status: "idle",
91
+ error: null
92
+ });
93
+ return;
94
+ }
95
+ sequence += 1;
96
+ abortInFlight();
97
+ setState({
98
+ query,
99
+ items: state.items,
100
+ status: "loading",
101
+ error: null
102
+ });
103
+ timer = globalThis.setTimeout(() => {
104
+ timer = null;
105
+ if (disposed) return;
106
+ runRequest(trimmed);
107
+ }, debounceMs);
108
+ },
109
+ setExcludedCompanySlugs(slugs) {
110
+ if (disposed) return;
111
+ excluded = new Set(
112
+ slugs.map((s) => s.trim().toLowerCase()).filter(Boolean)
113
+ );
114
+ setState({
115
+ query: state.query,
116
+ items: applyExclusions(rawItems, excluded),
117
+ status: state.status,
118
+ error: state.error
119
+ });
120
+ },
121
+ getState() {
122
+ return state;
123
+ },
124
+ subscribe(listener) {
125
+ if (disposed) return () => {
126
+ };
127
+ listeners.add(listener);
128
+ return () => {
129
+ listeners.delete(listener);
130
+ };
131
+ },
132
+ dispose() {
133
+ if (disposed) return;
134
+ disposed = true;
135
+ cancelTimer();
136
+ abortInFlight();
137
+ sequence += 1;
138
+ listeners.clear();
139
+ }
140
+ };
141
+ }
142
+ export {
143
+ createSuggestController
144
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cavuno/board",
3
- "version": "1.37.0",
3
+ "version": "1.39.0",
4
4
  "description": "Typed isomorphic client for the Cavuno Board API",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -50,6 +50,16 @@
50
50
  "default": "./dist/filters.js"
51
51
  }
52
52
  },
53
+ "./suggest": {
54
+ "import": {
55
+ "types": "./dist/suggest.d.mts",
56
+ "default": "./dist/suggest.mjs"
57
+ },
58
+ "require": {
59
+ "types": "./dist/suggest.d.ts",
60
+ "default": "./dist/suggest.js"
61
+ }
62
+ },
53
63
  "./theme": {
54
64
  "import": {
55
65
  "types": "./dist/theme.d.mts",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: cavuno-board-filters
3
- description: The canonical listing-filter vocabulary and URL search-param parsing with @cavuno/board/filters — remote/employment/seniority/sort sets, localized seniority labels, and parseListingFilters for jobs-listing routes. Use when building listing pages, filter sidebars, sort dropdowns, or validating listing URL params.
3
+ description: The canonical listing-filter vocabulary and URL search-param parsing with @cavuno/board/filters — remote/employment/seniority/company/sort sets, localized seniority labels, and parseListingFilters for jobs-listing routes. Use when building listing pages, filter sidebars, sort dropdowns, or validating listing URL params.
4
4
  ---
5
5
 
6
6
  # Filters: vocabulary + URL parsing
@@ -21,6 +21,7 @@ never throws).
21
21
  - Turning values into card display text — `cavuno-board-format`.
22
22
  - Search QUERIES (`jobs.search` bodies) — this module is about the listing
23
23
  URL contract, not the search POST body.
24
+ - Search-dropdown typeahead — `cavuno-board-suggest`.
24
25
 
25
26
  ## Parse listing URLs
26
27
 
@@ -28,19 +29,25 @@ never throws).
28
29
  import { parseListingFilters, DEFAULT_SORT } from '@cavuno/board/filters';
29
30
 
30
31
  const filters = parseListingFilters(rawSearchParams);
31
- // { q?, remoteOption?, employmentType?, seniority?: Seniority[], sort? }
32
+ // { q?, remoteOption?, employmentType?, seniority?: Seniority[], company?: string[], sort? }
32
33
 
33
34
  const page = await board.jobs.list({
34
35
  limit: 20,
35
36
  seniority: filters.seniority,
37
+ companySlug: filters.company,
36
38
  remoteOption: filters.remoteOption ? [filters.remoteOption] : undefined,
37
39
  employmentType: filters.employmentType ? [filters.employmentType] : undefined,
38
40
  });
39
41
  ```
40
42
 
41
43
  Unknown values are dropped silently (public URLs, never throw). Seniority
42
- accepts repeated params or a comma-string and normalizes with the hosted
43
- rules: trim, lowercase, dedupe, keep order.
44
+ and `company` accept repeated params or a comma-string and normalize with
45
+ the hosted rules: trim, lowercase, dedupe, keep order. `company` is an open
46
+ value set (public slugs) capped at 10 (wire max; first 10 kept).
47
+
48
+ **Slugs are the URL identity.** Map `filters.company` to the wire as
49
+ `companySlug` (`jobs.list` query / `jobs.search` filters). The API accepts
50
+ slugs directly — never resolve slug→id client-side.
44
51
 
45
52
  ## Render the filter UI from the vocabulary
46
53
 
@@ -35,7 +35,7 @@ for (const card of page.data) {
35
35
 
36
36
  ### Filters and pagination
37
37
 
38
- `JobsListQuery` supports `limit` (1–100), `offset` (takes precedence over `cursor`), `cursor`, and filters: `companyId`, `remoteOption`, `employmentType`, `seniority` (single or repeated → OR-matched), `location` + `radius` (km), `category`, `skill`. Paginate by passing back `nextCursor`, or use numbered pages with `offset`:
38
+ `JobsListQuery` supports `limit` (1–100), `offset` (takes precedence over `cursor`), `cursor`, and filters: `companyId`, `companySlug`, `remoteOption`, `employmentType`, `seniority` (single or repeated → OR-matched), `location` + `radius` (km), `category`, `skill`. Paginate by passing back `nextCursor`, or use numbered pages with `offset`:
39
39
 
40
40
  ```ts snippet
41
41
  const p1 = await board.jobs.list({ limit: 20 });
@@ -46,9 +46,24 @@ const p2 = p1.nextCursor
46
46
  const page3 = await board.jobs.list({ limit: 20, offset: 40 });
47
47
  ```
48
48
 
49
+ ### Company filter via public slug
50
+
51
+ `companySlug` is the public URL identity (what listing URLs carry). Prefer it
52
+ over `companyId` in frontends — the API resolves slugs server-side. Unknown
53
+ slugs are **ignored** (they contribute no matches, not an error); an entirely
54
+ unknown set yields an empty result list with `count: 0`. Combined with
55
+ `companyId` as a union when both are set. Cap is 10 values.
56
+
57
+ ```ts snippet
58
+ const page = await board.jobs.list({
59
+ limit: 20,
60
+ companySlug: ['acme', 'globex'],
61
+ });
62
+ ```
63
+
49
64
  ## Search
50
65
 
51
- `jobs.search` posts a `JobsSearchBody` (free-text `query` + structured `filters`) and returns a `JobCardSearchEnvelope`:
66
+ `jobs.search` posts a `JobsSearchBody` (free-text `query` + structured `filters`) and returns a `JobCardSearchEnvelope`. The same `companySlug` rule applies under `filters`:
52
67
 
53
68
  ```ts snippet
54
69
  const results = await board.jobs.search({
@@ -56,6 +71,7 @@ const results = await board.jobs.search({
56
71
  filters: {
57
72
  seniority: ['senior'],
58
73
  remoteOption: ['remote'],
74
+ companySlug: ['acme'],
59
75
  publishedAt: { gte: '2026-01-01T00:00:00Z' },
60
76
  },
61
77
  limit: 20,
@@ -0,0 +1,119 @@
1
+ ---
2
+ name: cavuno-board-suggest
3
+ description: Federated keyword search suggestions with the @cavuno/board SDK — board.search.suggest for companies + taxonomy terms in one server-ranked list, and the headless createSuggestController under @cavuno/board/suggest (debounce, abort, stale-drop). Use when building a search dropdown, typeahead, or autocomplete that mirrors the hosted board.
4
+ ---
5
+
6
+ # Search suggest: dropdown typeahead
7
+
8
+ `board.search.suggest` returns ONE interleaved, server-ordered list of
9
+ company and taxonomy-term suggestions for a keyword — the hosted search
10
+ dropdown contract. `@cavuno/board/suggest` wraps that call in a headless
11
+ controller so debounce / abort / stale-drop ship with the SDK and patch via
12
+ npm update.
13
+
14
+ ## When to use
15
+
16
+ - The global search input's suggestion dropdown.
17
+ - Any typeahead that should offer companies and category/skill terms
18
+ together, ranked the way the hosted board ranks them.
19
+
20
+ ## When not to use
21
+
22
+ - Location autocomplete — `board.taxonomy.places.list({ q })`.
23
+ - Taxonomy-only option lists for filter sidebars —
24
+ `board.taxonomy.categories/skills/suggestions.list`.
25
+ - Job results themselves — `board.jobs.list` / `board.jobs.search`.
26
+
27
+ ## Call the endpoint directly
28
+
29
+ ```ts snippet
30
+ const { items, query } = await board.search.suggest({ q: 'acme', limit: 10 });
31
+ // items is server-ranked — do NOT re-sort.
32
+ for (const item of items) {
33
+ if (item.type === 'company') {
34
+ item.slug; // public company URL slug
35
+ item.name;
36
+ item.jobCount;
37
+ } else {
38
+ // item.type === 'term'
39
+ item.termType; // 'category' | 'skill'
40
+ item.displayName;
41
+ item.canonicalSlug; // for links
42
+ item.sourceSlug; // for job filters
43
+ }
44
+ }
45
+ ```
46
+
47
+ Queries under two characters return an empty `items` array. `limit` is
48
+ 1–25 (default 25). Order is the contract.
49
+
50
+ ## Prefer the headless controller for UI
51
+
52
+ The controller owns debounce (default 250ms), min-chars (default 2),
53
+ in-flight abort, and stale-drop. Compatible with `useSyncExternalStore`.
54
+
55
+ ```ts snippet
56
+ import { createSuggestController } from '@cavuno/board/suggest';
57
+
58
+ const suggest = createSuggestController(board, { limit: 10 });
59
+ const unsub = suggest.subscribe(() => {
60
+ const { items, status, query } = suggest.getState();
61
+ // render dropdown; previous items are retained while status === 'loading'
62
+ });
63
+ suggest.setQuery(inputValue);
64
+ // Hide the currently-applied company filter from the list:
65
+ suggest.setExcludedCompanySlugs(appliedCompanySlugs);
66
+ // teardown:
67
+ unsub();
68
+ suggest.dispose();
69
+ ```
70
+
71
+ `setExcludedCompanySlugs` is the ONE permitted view-level filter (company
72
+ items only, re-filters current items synchronously — no new request). Never
73
+ reshape or re-sort the rest of the response.
74
+
75
+ ## Wire a company pick into the listing
76
+
77
+ Company slugs are the URL identity. Map them onto the jobs surface as
78
+ `companySlug` — never resolve slug→id client-side:
79
+
80
+ ```ts snippet
81
+ import { parseListingFilters } from '@cavuno/board/filters';
82
+
83
+ const filters = parseListingFilters(rawSearchParams);
84
+ // filters.company is string[] of public slugs
85
+
86
+ const page = await board.jobs.list({
87
+ limit: 20,
88
+ companySlug: filters.company,
89
+ });
90
+ ```
91
+
92
+ Unknown slugs are dropped server-side (not an error). If the entire company
93
+ filter resolves to no known companies, the listing is empty (`count: 0`).
94
+ Combined with `companyId` as a union when both are set.
95
+
96
+ ## Anti-patterns
97
+
98
+ ```ts no-check
99
+ // NEVER re-sort server-ranked items:
100
+ items.sort((a, b) => a.name.localeCompare(b.name));
101
+ // NEVER resolve company slug → id on the client:
102
+ const id = await lookupCompanyId(item.slug);
103
+ board.jobs.list({ companyId: [id] });
104
+ // NEVER hand-roll debounce/abort — use the controller so patches ship via npm.
105
+ ```
106
+
107
+ ## Out of scope — do not invent exports
108
+
109
+ No React components, no dropdown UI, no keyboard navigation — this package
110
+ is the data + controller layer. Location suggest stays on `taxonomy.places`.
111
+
112
+ ## Verify
113
+
114
+ - [ ] Typing under 2 characters shows no suggestions and no network call
115
+ from the controller.
116
+ - [ ] Fast typing cancels the previous request; only the final query's
117
+ results render.
118
+ - [ ] Selecting a company writes its public slug into the URL and the
119
+ listing request uses `companySlug`, not a client-resolved id.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.37.0",
2
+ "version": "1.39.0",
3
3
  "skills": [
4
4
  {
5
5
  "name": "cavuno-board-account",
@@ -52,7 +52,7 @@
52
52
  },
53
53
  {
54
54
  "name": "cavuno-board-filters",
55
- "description": "The canonical listing-filter vocabulary and URL search-param parsing with @cavuno/board/filters — remote/employment/seniority/sort sets, localized seniority labels, and parseListingFilters for jobs-listing routes. Use when building listing pages, filter sidebars, sort dropdowns, or validating listing URL params.",
55
+ "description": "The canonical listing-filter vocabulary and URL search-param parsing with @cavuno/board/filters — remote/employment/seniority/company/sort sets, localized seniority labels, and parseListingFilters for jobs-listing routes. Use when building listing pages, filter sidebars, sort dropdowns, or validating listing URL params.",
56
56
  "path": "skills/cavuno-board-filters/SKILL.md",
57
57
  "framework": null,
58
58
  "category": "core"
@@ -148,6 +148,13 @@
148
148
  "framework": null,
149
149
  "category": "core"
150
150
  },
151
+ {
152
+ "name": "cavuno-board-suggest",
153
+ "description": "Federated keyword search suggestions with the @cavuno/board SDK — board.search.suggest for companies + taxonomy terms in one server-ranked list, and the headless createSuggestController under @cavuno/board/suggest (debounce, abort, stale-drop). Use when building a search dropdown, typeahead, or autocomplete that mirrors the hosted board.",
154
+ "path": "skills/cavuno-board-suggest/SKILL.md",
155
+ "framework": null,
156
+ "category": "core"
157
+ },
151
158
  {
152
159
  "name": "cavuno-board-tanstack-start",
153
160
  "description": "TanStack-Start-on-Cloudflare-Workers reference wiring for a headless Cavuno board — SSR loaders calling @cavuno/board server-side, the session held in an __Host- httpOnly cookie owned by the app, a single-flight refresh helper, and FetchOptions cache passthrough on Workers.",