@hadoku/jobplatform 0.0.0-stage → 3.41.15

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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +185 -2
  3. package/dist/App.d.ts +9 -0
  4. package/dist/App.d.ts.map +1 -0
  5. package/dist/api/applyQueue.d.ts +42 -0
  6. package/dist/api/applyQueue.d.ts.map +1 -0
  7. package/dist/api/auth.d.ts +15 -0
  8. package/dist/api/auth.d.ts.map +1 -0
  9. package/dist/api/companies.d.ts +26 -0
  10. package/dist/api/companies.d.ts.map +1 -0
  11. package/dist/api/jobs.d.ts +388 -0
  12. package/dist/api/jobs.d.ts.map +1 -0
  13. package/dist/api/packets.d.ts +28 -0
  14. package/dist/api/packets.d.ts.map +1 -0
  15. package/dist/api/profiles.d.ts +64 -0
  16. package/dist/api/profiles.d.ts.map +1 -0
  17. package/dist/api/resource.d.ts +43 -0
  18. package/dist/api/resource.d.ts.map +1 -0
  19. package/dist/api/useResource.d.ts +26 -0
  20. package/dist/api/useResource.d.ts.map +1 -0
  21. package/dist/components/ApplicationsList.d.ts +15 -0
  22. package/dist/components/ApplicationsList.d.ts.map +1 -0
  23. package/dist/components/CompaniesManager.d.ts +10 -0
  24. package/dist/components/CompaniesManager.d.ts.map +1 -0
  25. package/dist/components/FillExtensionInstall.d.ts +7 -0
  26. package/dist/components/FillExtensionInstall.d.ts.map +1 -0
  27. package/dist/components/JobCard.d.ts +17 -0
  28. package/dist/components/JobCard.d.ts.map +1 -0
  29. package/dist/components/JobDrawer.d.ts +17 -0
  30. package/dist/components/JobDrawer.d.ts.map +1 -0
  31. package/dist/components/JobsList.d.ts +18 -0
  32. package/dist/components/JobsList.d.ts.map +1 -0
  33. package/dist/components/PacketDetail.d.ts +16 -0
  34. package/dist/components/PacketDetail.d.ts.map +1 -0
  35. package/dist/components/PacketsList.d.ts +16 -0
  36. package/dist/components/PacketsList.d.ts.map +1 -0
  37. package/dist/components/ProfileEditorModal.d.ts +15 -0
  38. package/dist/components/ProfileEditorModal.d.ts.map +1 -0
  39. package/dist/components/ProfileSidebar.d.ts +24 -0
  40. package/dist/components/ProfileSidebar.d.ts.map +1 -0
  41. package/dist/components/UnansweredQuestions.d.ts +9 -0
  42. package/dist/components/UnansweredQuestions.d.ts.map +1 -0
  43. package/dist/components/VerificationCodes.d.ts +12 -0
  44. package/dist/components/VerificationCodes.d.ts.map +1 -0
  45. package/dist/components/VoteControl.d.ts +23 -0
  46. package/dist/components/VoteControl.d.ts.map +1 -0
  47. package/dist/components/matchOption.d.ts +16 -0
  48. package/dist/components/matchOption.d.ts.map +1 -0
  49. package/dist/entry.d.ts +24 -0
  50. package/dist/entry.d.ts.map +1 -0
  51. package/dist/fillExtension/bundle.d.ts +4 -0
  52. package/dist/fillExtension/bundle.d.ts.map +1 -0
  53. package/dist/fillExtension/zip.d.ts +12 -0
  54. package/dist/fillExtension/zip.d.ts.map +1 -0
  55. package/dist/index.js +5687 -0
  56. package/dist/style.css +1 -0
  57. package/package.json +35 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matthaeus Wolff
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,186 @@
1
- # Temporary Holding Version
1
+ # hadoku-jobplatform
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An end-to-end job-search pipeline. It pulls postings off company ATS boards, scores
4
+ them against role profiles you define, lets you triage what survives, and assembles a
5
+ full application packet — tailored resume, cover letter, intro email, screening
6
+ answers — for the ones worth applying to.
7
+
8
+ Live at **[hadoku.me/jobplatform](https://hadoku.me/jobplatform)** (~7,600 postings in
9
+ the corpus as of August 2026). The job list is public; profiles, triage state, and
10
+ application packets need an account.
11
+
12
+ This is one app in the [hadoku.me](https://hadoku.me) ecosystem — a personal platform
13
+ where each app ships as an independently versioned npm package and gets mounted by a
14
+ parent site. Nothing here runs standalone; see [How it fits together](#how-it-fits-together).
15
+
16
+ ---
17
+
18
+ ## The problem it solves
19
+
20
+ Job boards rank by recency and their own incentives. Searching them means re-reading
21
+ the same 200 postings weekly to find the four that changed. This inverts that: describe
22
+ the role you want _once_, and every scrape run re-ranks the whole corpus against it.
23
+
24
+ Three things make that non-trivial, and most of the interesting code is in the answers:
25
+
26
+ **No ATS publishes seniority.** Not Greenhouse, not Lever, not Ashby. "Staff Engineer"
27
+ vs "Engineering Manager" exists only in the title string. So role is inferred at ingest
28
+ ([`worker/src/roleClassify.ts`](worker/src/roleClassify.ts)) along **two orthogonal
29
+ axes** rather than one list:
30
+
31
+ - `track` — `ic` or `manager`. Answers "does this job have direct reports?"
32
+ - `level` — the rung on that track's ladder.
33
+
34
+ They're orthogonal because a flat list makes `senior` and `manager` look like
35
+ alternatives to each other, which is nonsense — they're answers to different questions.
36
+ Classification reads the _head_ of the title, before the first comma, so
37
+ "Software Engineer, Ads Manager" correctly lands as an IC. Only the genuinely ambiguous
38
+ `lead` family falls back to probing the description body.
39
+
40
+ **Filtering and ranking are different requests.** "Only show me IC roles" is not "rank
41
+ IC roles higher." Company, `track`, and salary floor are **hard SQL filters**; everything
42
+ else is a weighted score. Conflating the two produces a feed that's technically sorted
43
+ and practically useless.
44
+
45
+ **Salary is mostly absent.** `salary_min` is NULL on most postings, so scoring it
46
+ returned a neutral ~0.5 for nearly everything — pure noise dressed as signal. It was
47
+ removed as a score factor and demoted to a view filter. Postings with no listed salary
48
+ survive the filter, because hiding them would empty the feed rather than narrow it.
49
+
50
+ ### Scoring
51
+
52
+ Runs on read, per request — so a score always reflects the profile as it is _now_
53
+ rather than as it was when some batch job last ran. (The precomputed match table was
54
+ dropped in migration 0008 for exactly this reason.)
55
+
56
+ | Signal | Weight | What it measures |
57
+ | --------------- | -----: | ------------------------------------------- |
58
+ | `keyword_match` | 0.40 | profile keywords against the description |
59
+ | `title_match` | 0.30 | profile keywords against the title |
60
+ | `level_match` | 0.15 | distance on the seniority ladder |
61
+ | `remote_match` | 0.15 | remote / hybrid / onsite against preference |
62
+
63
+ `level_match` grades by _distance_, not equality: exact rung 1.0, one rung off 0.7,
64
+ further or off-track 0.2 — and an unclassified job scores 0.5, because no signal is not
65
+ the same as a bad match. Full algorithm and data model:
66
+ [`ARCHITECTURE.md`](./ARCHITECTURE.md).
67
+
68
+ ---
69
+
70
+ ## Architecture
71
+
72
+ Two independently published packages live in this repo:
73
+
74
+ | Package | Path | What it is |
75
+ | ---------------------------- | --------- | ----------------------------------------------- |
76
+ | `@wolffm/jobplatform` | `src/` | React micro-frontend, exports `mount`/`unmount` |
77
+ | `@wolffm/jobplatform-worker` | `worker/` | Cloudflare Worker API (Hono + D1) |
78
+
79
+ The pipeline, end to end:
80
+
81
+ ```
82
+ Profile: keywords, track, levels, remote pref, companies
83
+ │
84
+ ▼
85
+ GET /directives ──────────► hadoku-scrape (Python, separate repo)
86
+ the union of every │ resolves company → (ats, slug)
87
+ profile's companies + │ enumerates Greenhouse / Lever / LinkedIn
88
+ keywords. The scraper │ archives raw postings to Cloudflare KV
89
+ PULLS this each run. │
90
+ ▼
91
+ POST /ingest (full postings inline, batches of 25)
92
+ │
93
+ ▼
94
+ ┌───────────────────────────┐
95
+ │ jobplatform-worker │
96
+ │ • dedup by URL │
97
+ │ • classify track + level │
98
+ │ • parse (ats, slug) │
99
+ │ • store in D1 │
100
+ └───────────┬───────────────┘
101
+ │ score on read, per request
102
+ ▼
103
+ ┌───────────────────────────┐
104
+ │ jobplatform (React) │
105
+ │ • ranked feed + filters │
106
+ │ • triage: interested / │
107
+ │ saved / applied / … │
108
+ │ • job detail + breakdown │
109
+ └───────────┬───────────────┘
110
+ │ service binding
111
+ ▼
112
+ resume-api → resume, cover letter, intro email,
113
+ screening answers → one shareable packet
114
+ ```
115
+
116
+ Companies are a **directive the scraper pulls**, not targets this app pushes. The worker
117
+ publishes what it wants scraped at `GET /directives`; the scraper reads that on its next
118
+ run and registers targets itself. That keeps registry ownership in one place — the
119
+ scraper owns _how_ to scrape, this app owns _what_ is worth scraping.
120
+
121
+ Full API table and design rationale: [`CLAUDE.md`](./CLAUDE.md).
122
+
123
+ ---
124
+
125
+ ## How it fits together
126
+
127
+ ```
128
+ hadoku_site (parent)
129
+ ├── mounts @wolffm/jobplatform at hadoku.me/jobplatform
130
+ └── hosts @wolffm/jobplatform-worker at hadoku.me/jobplatform/api
131
+ │
132
+ ┌───────────────────┼───────────────────┐
133
+ ▼ ▼ ▼
134
+ hadoku-scrape resume-bot Cloudflare D1
135
+ (postings) (resume/cover) (scored corpus)
136
+ ```
137
+
138
+ Push to `main` publishes both packages to GitHub Packages and fires a
139
+ `packages_updated` dispatch; the parent site pulls the new versions and redeploys.
140
+
141
+ Auth is tier-ranked (`public < friend < service < admin`), gated in-worker. The parent's
142
+ edge router resolves a session cookie into a user key and stamps the tier; the worker
143
+ trusts that stamp only when its provenance header verifies, so a request that bypasses
144
+ the edge degrades to `public` rather than being trusted.
145
+
146
+ ---
147
+
148
+ ## Development
149
+
150
+ Requires `pnpm`. The dev server needs secrets brokered from the parent repo, so a
151
+ standalone clone will build and test but not fully run.
152
+
153
+ ```bash
154
+ pnpm install
155
+
156
+ pnpm dev # dev server on :5173
157
+ pnpm build # build both packages
158
+ pnpm lint # eslint
159
+ pnpm lint:css # stylelint + theme-token check
160
+ pnpm typecheck
161
+
162
+ cd worker && pnpm test # node:test, no framework
163
+ ```
164
+
165
+ To act as an authenticated user in dev, pass `?apiKey=<key>` once — the harness in
166
+ `index.html` exchanges it for a session id through the same code path production uses.
167
+ The URL parameter is dev-only and never accepted in production.
168
+
169
+ **Colors** come from `@wolffm/themes` as semantic CSS custom properties
170
+ (`var(--color-primary)`, `var(--color-on-primary-bg)`, …). Light and dark are automatic;
171
+ never branch on theme mode, and never hardcode a hex value. `pnpm lint:css` fails the
172
+ build on a reference to a token the theme doesn't define — worth running, since an
173
+ undefined token renders as nothing rather than erroring.
174
+
175
+ **Logging** goes through the logger from `@wolffm/task-ui-components`, not `console.log`:
176
+
177
+ ```typescript
178
+ import { logger } from '@wolffm/task-ui-components'
179
+ logger.info('Message', { key: 'value' })
180
+ ```
181
+
182
+ ---
183
+
184
+ ## License
185
+
186
+ [MIT](./LICENSE).
package/dist/App.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import type { JobPlatformProps } from './entry';
2
+ /**
3
+ * Provider boundary. Theme state belongs to the platform (@wolffm/themes),
4
+ * not to this app — the local hooks/useTheme.ts, prefs/themePrefs.ts and
5
+ * app/themeConfig.tsx copies are gone. AppHeader renders the shared picker
6
+ * from this context, so nothing below passes one.
7
+ */
8
+ export default function App(props?: JobPlatformProps): import("react/jsx-runtime").JSX.Element;
9
+ //# sourceMappingURL=App.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"App.d.ts","sourceRoot":"","sources":["../src/App.tsx"],"names":[],"mappings":"AA2BA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAS/C;;;;;GAKG;AACH,MAAM,CAAC,OAAO,UAAU,GAAG,CAAC,KAAK,GAAE,gBAAqB,2CAOvD"}
@@ -0,0 +1,42 @@
1
+ import type { Auth } from './auth';
2
+ /** Where one job is in the sequence. `error` carries a reason; the rest don't. */
3
+ export type ApplyPhase = 'idle' | 'waiting' | 'preparing' | 'queueing' | 'queued' | 'error';
4
+ export interface ApplyStatus {
5
+ phase: ApplyPhase;
6
+ error?: string;
7
+ }
8
+ /**
9
+ * Run the whole sequence for one job and return the packet slug.
10
+ *
11
+ * The two generation calls are SEQUENCED, not concurrent. The résumé is the
12
+ * token-heavy one (block selection + rewrite), so it runs alone; then a single
13
+ * application-extras call returns the cover letter and the rest of the kit
14
+ * together. Firing the cover letter and the extras as separate back-to-back
15
+ * calls used to stack past Groq's per-minute token cap so the trailing one
16
+ * 500'd, leaving a packet with a résumé and no kit.
17
+ */
18
+ export declare function prepareApplicationPacket(jobId: string, auth: Auth, onPhase?: (phase: ApplyPhase) => void): Promise<{
19
+ slug: string;
20
+ url: string;
21
+ resume: string;
22
+ coverLetter: string;
23
+ }>;
24
+ /**
25
+ * Handing a posting to the runner is now ONE request.
26
+ *
27
+ * It used to be four: résumé, kit, mint, queue — about 21 seconds of LLM work
28
+ * per posting, run in this tab behind a one-at-a-time lane so the generations
29
+ * could not stack past the per-minute token cap. That made a click look
30
+ * accepted while nothing durable existed. Queueing eight postings meant the
31
+ * last began two and a half minutes later, and closing the tab, reloading, or
32
+ * switching to Applications discarded everything the lane had not reached —
33
+ * along with every error, so there was no record the owner had ever asked.
34
+ *
35
+ * `POST /jobs/:id/apply` no longer needs a minted packet. The row is written
36
+ * immediately and the runner mints the packet just before it fills, so the
37
+ * click is a single fast write that survives the tab. The lane is gone with
38
+ * the work it was protecting.
39
+ */
40
+ /** Hand one posting to the runner. One request, durable the moment it returns. */
41
+ export declare function enqueueApply(jobId: string, auth: Auth, onStatus: (status: ApplyStatus) => void): Promise<void>;
42
+ //# sourceMappingURL=applyQueue.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"applyQueue.d.ts","sourceRoot":"","sources":["../../src/api/applyQueue.ts"],"names":[],"mappings":"AAwBA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAA;AAElC,kFAAkF;AAClF,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,SAAS,GAAG,WAAW,GAAG,UAAU,GAAG,QAAQ,GAAG,OAAO,CAAA;AAE3F,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,UAAU,CAAA;IACjB,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED;;;;;;;;;GASG;AACH,wBAAsB,wBAAwB,CAC5C,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,IAAI,EACV,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,GACpC,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,CAAC,CA6B7E;AAED;;;;;;;;;;;;;;;GAeG;AAEH,kFAAkF;AAClF,wBAAsB,YAAY,CAChC,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,IAAI,EACV,QAAQ,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,IAAI,GACtC,OAAO,CAAC,IAAI,CAAC,CAgBf"}
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Who the parent app (hadoku_site) says is looking at this MFE.
3
+ *
4
+ * The credential never enters the bundle. A same-origin `fetch` carries the
5
+ * HttpOnly `hadoku_session` cookie on its own, and edge-router resolves it
6
+ * into `X-User-Key` for the worker. The session id used to arrive here as a
7
+ * prop and go out as `X-Session-Id`, which let page JavaScript read a 30-day
8
+ * bearer credential (hadoku_site#459) — so all this carries is whether anyone
9
+ * is signed in, from mf-loader's `userType` prop.
10
+ */
11
+ export interface Auth {
12
+ signedIn: boolean;
13
+ }
14
+ export declare function authHeaders(_auth?: Auth, json?: boolean): HeadersInit;
15
+ //# sourceMappingURL=auth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/api/auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,MAAM,WAAW,IAAI;IACnB,QAAQ,EAAE,OAAO,CAAA;CAClB;AAED,wBAAgB,WAAW,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,IAAI,UAAQ,GAAG,WAAW,CAInE"}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Company lookups behind the profile "add company" flow.
3
+ * Subscriptions themselves live per-profile — see api/profiles.ts.
4
+ * Auth model: see `./auth.ts`.
5
+ */
6
+ import { type Auth } from './auth';
7
+ export interface CompanyMatch {
8
+ query: string;
9
+ matched: boolean;
10
+ ats: string | null;
11
+ slug: string | null;
12
+ company_name: string | null;
13
+ n_jobs: number;
14
+ sample_titles: string[];
15
+ domain: string | null;
16
+ }
17
+ export declare class CompaniesApiError extends Error {
18
+ status: number;
19
+ constructor(message: string, status: number);
20
+ }
21
+ /**
22
+ * Name-driven prefetch: type company names, get the single best board each
23
+ * (most open jobs) to confirm before adding to a profile. Read-only.
24
+ */
25
+ export declare function matchCompanies(names: string[], auth?: Auth): Promise<CompanyMatch[]>;
26
+ //# sourceMappingURL=companies.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"companies.d.ts","sourceRoot":"","sources":["../../src/api/companies.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAe,KAAK,IAAI,EAAE,MAAM,QAAQ,CAAA;AAW/C,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,EAAE,OAAO,CAAA;IAChB,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,MAAM,EAAE,MAAM,CAAA;IACd,aAAa,EAAE,MAAM,EAAE,CAAA;IACvB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CACtB;AAED,qBAAa,iBAAkB,SAAQ,KAAK;IAGjC,MAAM,EAAE,MAAM;gBADrB,OAAO,EAAE,MAAM,EACR,MAAM,EAAE,MAAM;CAKxB;AAaD;;;GAGG;AACH,wBAAsB,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAS1F"}