Skip to content
Merged
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
14 changes: 14 additions & 0 deletions docs/agents/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,20 @@ Saved answer revisions and usage accounting are the same as for catalog jobs.
Jumping from the unanswered-field checklist focuses the form control without
collapsing the extension. Panel minimization is an explicit user action.

After a fill with populated fields, the extension calls the owning fill's
`track` endpoint. It adds the canonical posting through the shared import
service, links the fill, and records personal tracking intent atomically.
Tracking never assigns application status or date and preserves existing
notes and hidden state. Submission remains a separate confirmation.
Failures leave the form intact and offer a board-save retry. New postings
use the existing extraction task; retries share that task.

Apply context includes the current user's saved status, application date and
latest confirmed fill submission for a matched job. The extension displays
them before autofill, without presenting an arbitrary saved status as proof
of submission. Another user's private posting or application state is not
part of this context.

The extension's per-field review uses `GET /user/apply/fills/{id}` and
`PUT /user/apply/fills/{id}/answer`. The latter requires the field's
`answer_revision`, saves `review_value` and feedback, and appends history without
Expand Down
4 changes: 2 additions & 2 deletions extension/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion extension/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "job-tracker-apply",
"private": true,
"version": "0.3.5",
"version": "0.3.6",
"type": "module",
"scripts": {
"dev": "node scripts/entrypoints.mjs && wxt",
Expand Down
26 changes: 25 additions & 1 deletion extension/runtime/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export async function startApplication(adapter, adapterContext, lifecycle) {
const reader = adapter || { ready: () => false, submitButton: () => null, submitted: () => false };
// Stamped into every report, so a report from a build the person has not
// reloaded yet is told apart from a bug (reports 9 to 11, 2026-09-08).
const BUILD = "0.3.5 external application context";
const BUILD = "0.3.6 track filled applications";

// A message to the extension's background worker. After the extension is
// reloaded, a page that was already open keeps the old script, whose
Expand Down Expand Up @@ -211,6 +211,7 @@ export async function startApplication(adapter, adapterContext, lifecycle) {
</div>
<div class="body">
<div class="application-context"><span class="eyebrow">Current application</span><div class="application-title">${esc(matchedTitle() || submission?.title || document.title || "Application form")}</div><span class="muted">${esc(location.hostname)}${context ? (context.job ? " · on your board" : " · not on your board") : ""}</span></div>
${previousApplicationNotice()}
<div id="jt-operation" role="status">${operationControls()}</div>
<div class="workspace">${html}</div>
<details class="settings"><summary>Autofill preferences</summary>
Expand Down Expand Up @@ -376,6 +377,12 @@ export async function startApplication(adapter, adapterContext, lifecycle) {
let context = null;
let contextState = "idle";
let contextGeneration = 0;
function previousApplicationNotice() {
const job = context?.job;
if (!job || !(job.status || job.date_applied || job.submitted_at)) return "";
const date = job.date_applied || job.submitted_at?.slice(0, 10);
return `<div class="notice warn" role="status" aria-label="Existing application"><strong>${job.submitted_at ? "You already submitted this application." : "Existing application record"}</strong><p>${esc(job.status || "Submission recorded")}${date ? ` · ${esc(date)}` : ""}</p><p>Filling again will keep your existing status and application date.</p></div>`;
}
async function loadContext() {
if (contextState !== "idle") return;
contextState = "loading";
Expand Down Expand Up @@ -577,6 +584,7 @@ export async function startApplication(adapter, adapterContext, lifecycle) {
}
if (allowed("ai_suggestions")) await askModel(fill.fields.filter((e) => !isFilled(e) && askable(e)));
await verify();
if (fill.fields.some(isFilled)) await trackFilledJob();
show();
await autoReport(`filled page ${step + 1}`);
// A form that spans pages: when this page has a Continue and no Submit,
Expand Down Expand Up @@ -708,9 +716,24 @@ export async function startApplication(adapter, adapterContext, lifecycle) {
}
}

async function trackFilledJob() {
const current = fill;
const result = await api(`user/apply/fills/${current.fill_id}/track`, "POST", {});
if (fill !== current) return;
if (result.ok) {
fill.job_id = result.json.job_id;
fill.board_saved = true;
fill.board_error = false;
contextState = "idle";
await loadContext();
} else fill.board_error = true;
}

function show() {
const unconfirmed = fill.fields.filter(entry => filled.get(entry.key)?.error);
render(`
${fill.board_saved ? `<p class="muted">Saved to your board. Existing status and application date preserved.</p>` : ""}
${fill.board_error ? `<p class="notice warn" role="alert">Your form was filled, but saving this job to your board failed.</p><button id="jt-track-retry">Retry saving to board</button>` : ""}
${fill.ai_error ? `<p class="notice warn" role="alert">Could not prepare AI answers: ${esc(fill.ai_error)}.</p>` : ""}
${fill.stopped ? `<p class="notice warn" role="alert">${esc(fill.stopped)}</p>` : ""}
${unconfirmed.length ? `<div class="notice warn" role="alert"><p>Some fields could not be confirmed. Review these before submitting:</p><ul>${unconfirmed.map(entry => `<li>${esc(entry.label || entry.key)}: ${esc(filled.get(entry.key).error)}</li>`).join("")}</ul></div>` : ""}
Expand All @@ -719,6 +742,7 @@ export async function startApplication(adapter, adapterContext, lifecycle) {
<details class="help"><summary>What gets remembered?</summary><p>Drafts and suggestions are saved with this application. After submission, choices and short answers can be reused for the same question. The final submission record keeps the values actually on the form.</p></details>
`);
on("click", "jt-again", run);
on("click", "jt-track-retry", async () => { await trackFilledJob(); show(); });
drawReview();
if (!reviewState) loadReview();
}
Expand Down
27 changes: 27 additions & 0 deletions extension/tests/browser/tracking.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { test, expect } from "@playwright/test";

test("existing submission is visible before filling", async ({ page }) => {
await page.goto("/tests/extension/panel-preview.html?reset=1&applied=1");
const notice = page.getByRole("status", { name: "Existing application" });
await expect(notice).toContainText("You already submitted this application.");
await expect(notice).toContainText("Application Submitted · 2026-09-01");
await expect(page.locator("#name")).toHaveValue("");
});

test("completed autofill saves to the board without submitting", async ({ page }) => {
await page.goto("/tests/extension/panel-preview.html?reset=1");
await page.locator("#jt-autofill").click();
await expect(page.getByText("Saved to your board. Existing status and application date preserved.", { exact: true })).toBeVisible();
await expect(page.locator("body")).toHaveAttribute("data-track-requests", "1");
await expect(page.locator("#name")).toHaveValue("Alex Morgan");
await expect(page.locator("#demo-submit")).toBeVisible();
});

test("a failed board save keeps the form and offers retry", async ({ page }) => {
await page.goto("/tests/extension/panel-preview.html?reset=1&track-error=1");
await page.locator("#jt-autofill").click();
await expect(page.getByRole("button", { name: "Retry saving to board" })).toBeVisible();
await expect(page.locator("#name")).toHaveValue("Alex Morgan");
await page.getByRole("button", { name: "Retry saving to board" }).click();
await expect(page.locator("body")).toHaveAttribute("data-track-requests", "2");
});
192 changes: 192 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -5680,6 +5680,145 @@
}
}
},
"/v1/user/apply/fills/{fill_id}/track": {
"post": {
"summary": "Track Fill",
"operationId": "track_fill_v1_user_apply_fills__fill_id__track_post",
"parameters": [
{
"name": "fill_id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"title": "Fill Id"
}
},
{
"name": "x-service-token",
"in": "header",
"required": false,
"schema": {
"type": "string",
"default": "",
"title": "X-Service-Token"
}
},
{
"name": "x-user-sub",
"in": "header",
"required": false,
"schema": {
"type": "string",
"default": "",
"title": "X-User-Sub"
}
},
{
"name": "x-user-email",
"in": "header",
"required": false,
"schema": {
"type": "string",
"default": "",
"title": "X-User-Email"
}
},
{
"name": "x-user-name",
"in": "header",
"required": false,
"schema": {
"type": "string",
"default": "",
"title": "X-User-Name"
}
},
{
"name": "x-user-groups",
"in": "header",
"required": false,
"schema": {
"type": "string",
"default": "",
"title": "X-User-Groups"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TrackedFill"
}
}
}
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemResponse"
}
}
},
"description": "Bad Request"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemResponse"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemResponse"
}
}
},
"description": "Forbidden"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemResponse"
}
}
},
"description": "Not Found"
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemResponse"
}
}
},
"description": "Conflict"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/v1/extension/config": {
"get": {
"summary": "Extension Config",
Expand Down Expand Up @@ -43622,6 +43761,41 @@
}
],
"title": "Title"
},
"status": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Status"
},
"date_applied": {
"anyOf": [
{
"type": "string",
"format": "date"
},
{
"type": "null"
}
],
"title": "Date Applied"
},
"submitted_at": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Submitted At"
}
},
"type": "object",
Expand Down Expand Up @@ -55369,6 +55543,24 @@
"title": "Totals",
"description": "The bookkeeping rows, deliberately not the board. Counted from\nuser_jobs, so `tracked` includes rows whose posting has since left."
},
"TrackedFill": {
"properties": {
"job_id": {
"type": "integer",
"title": "Job Id"
},
"url": {
"type": "string",
"title": "Url"
}
},
"type": "object",
"required": [
"job_id",
"url"
],
"title": "TrackedFill"
},
"Tunable": {
"properties": {
"type": {
Expand Down
13 changes: 13 additions & 0 deletions src/api/board/person_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@
USER_JOB_SPLIT_DEDUPE_PREFIX = "user-job-split:v1"


def track_board_row(user_id: int, job_id: int) -> dict:
"""Record tracking intent without changing application status or dates."""
return (
db.query_one(
"INSERT INTO user_jobs (user_id, job_id, person_touched_at) VALUES (%s, %s, now()) "
"ON CONFLICT (user_id, job_id) DO UPDATE SET person_touched_at = now(), updated_at = now() "
"RETURNING status, date_applied, hidden",
(user_id, job_id),
)
or {}
)


def touchable_job_ids(user_id: int, job_ids: list[int]) -> set[int]:
"""The ids this user may write a board row for.

Expand Down
Loading
Loading