From 47ab769f6ae15b6a60bbe284f89587e2da8329aa Mon Sep 17 00:00:00 2001 From: Timothy Oluwatobi Ojebiyi Date: Tue, 3 Nov 2026 09:12:44 +0000 Subject: [PATCH 1/4] feat: every application keeps its status history, and the list shows the last 6 months by default Each status change is logged with when it happened and who made it (you, autopilot or an email); applications from before the log get what can be rebuilt from their applied date and emails. The panel's timeline shows it. An undone email takes its change back out of the log. Emails date a change by when they arrived, not when they were read. The applied date is now the day it was first applied. Saving a note on an Applied card used to reset it to today. The Applications page gains a range: 6 months (the default), 1 year, 2 years, All. --- api/app/autopilot.py | 4 +- api/app/gmail.py | 20 ++++-- api/app/tracker.py | 91 +++++++++++++++++++++++--- api/tests/test_applications.py | 79 ++++++++++++++++++++++ frontend/e2e/smoke.spec.ts | 25 +++++++ frontend/src/app/applications/page.tsx | 34 +++++++--- frontend/src/lib/types.ts | 7 ++ 7 files changed, 232 insertions(+), 28 deletions(-) diff --git a/api/app/autopilot.py b/api/app/autopilot.py index 2b8ac67..caa54d1 100644 --- a/api/app/autopilot.py +++ b/api/app/autopilot.py @@ -483,14 +483,14 @@ async def ask_code(asked: str, attempt: int) -> str | None: app_id = (tracker.add_application(user_id, tracked, {}, text, {"fit_score": verdict.get("fit_score"), "level_match": verdict.get("level_match", "ok"), "skill_gaps": verdict.get("skill_gaps") or []}, - tailored_cv=json.dumps(cv), status=status) + tailored_cv=json.dumps(cv), status=status, source="autopilot") or tracker.find_application_id(user_id, job.get("company", ""), job.get("title", ""))) if app_id: note = {"submitted": "Sent by autopilot.", "unconfirmed": "Autopilot clicked submit but saw no confirmation.", "needs_you": "Autopilot filled this in and held it for you."}[outcome] if final.get("code_asked"): note = "Autopilot filled this in, but the job site asked for a code it emailed you: apply on its page." - tracker.update_status(app_id, status, note, user_id=user_id) + tracker.update_status(app_id, status, note, user_id=user_id, source="autopilot") tracker.set_application_questions(app_id, questions, user_id=user_id) reused = (f"Used the CV and cover letter from the {'practice ' if earlier['practice'] else ''}run of " f"{earlier['updated_at'][:10]}. " if earlier else "") diff --git a/api/app/gmail.py b/api/app/gmail.py index 65a21c1..4303b76 100644 --- a/api/app/gmail.py +++ b/api/app/gmail.py @@ -415,7 +415,7 @@ def _apply(user_id: str, email: dict, reading: dict) -> str: if target is None: decision = "noted" elif safe and reading["confidence"] == "high": - tracker.update_status(app["id"], target, user_id=user_id) + tracker.update_status(app["id"], target, user_id=user_id, source="email", at=email["received_at"]) decision = "applied" else: decision = "suggested" @@ -517,7 +517,7 @@ def confirm(user_id: str, event_id: str, application_id: str | None = None) -> d raise GmailError("Choose which application this email is about.") target = KIND_STATUS.get(event["kind"]) if target and target != app["status"]: - tracker.update_status(app["id"], target, user_id=user_id) + tracker.update_status(app["id"], target, user_id=user_id, source="email", at=event["received_at"]) tracker.update_email_event(user_id, event_id, decision="confirmed", application_id=app["id"], proposed=target, status_before=app["status"]) return tracker.get_email_event(user_id, event_id) @@ -536,9 +536,10 @@ def track(user_id: str, event_id: str, company: str, role: str) -> dict: raise GmailError("Give the company and the role.") status = KIND_STATUS.get(event["kind"]) or "Applied" job = {"job_id": f"email:{event['gmail_id']}", "title": role, "company": company} - app_id = (tracker.add_application(user_id, job, {}, "", {}, status="Applied") + app_id = (tracker.add_application(user_id, job, {}, "", {}, status="Applied", source="email", at=event["received_at"]) or tracker.find_application_id(user_id, company, role)) - tracker.update_status(app_id, status, f"Added from your inbox: {event['subject']}"[:300], user_id=user_id) + tracker.update_status(app_id, status, f"Added from your inbox: {event['subject']}"[:300], user_id=user_id, + source="email", at=event["received_at"]) tracker.update_email_event(user_id, event_id, decision="confirmed", application_id=app_id, proposed=status, status_before=None) return tracker.get_email_event(user_id, event_id) @@ -573,10 +574,15 @@ def track_all(user_id: str) -> dict: newest = events[-1] status = next((KIND_STATUS[e["kind"]] for e in reversed(events) if e["kind"] in KIND_STATUS), "Applied") job = {"job_id": f"email:{newest['gmail_id']}", "title": newest["role"], "company": newest["company"]} - app_id = tracker.add_application(user_id, job, {}, "", {}, status="Applied") + app_id = tracker.add_application(user_id, job, {}, "", {}, status="Applied", source="email", + at=events[0]["received_at"]) if app_id: added += 1 - tracker.update_status(app_id, status, f"Added from your inbox: {newest['subject']}"[:300], user_id=user_id) + for e in events: # each step its emails show, so its history is the path it took + if e["kind"] in KIND_STATUS: + tracker.update_status(app_id, KIND_STATUS[e["kind"]], user_id=user_id, source="email", at=e["received_at"]) + tracker.update_status(app_id, status, f"Added from your inbox: {newest['subject']}"[:300], user_id=user_id, + source="email", at=newest["received_at"]) else: # already tracked under that company and role: link to it, and leave its status to the usual rules app_id = tracker.find_application_id(user_id, newest["company"], newest["role"]) linked += 1 @@ -607,7 +613,7 @@ def undo(user_id: str, event_id: str) -> dict: raise GmailError("That application no longer exists.") if event["proposed"] and app["status"] != event["proposed"]: raise GmailError(f"It has moved on since (it's now {app['status']}): change it on the board instead.") - tracker.update_status(app["id"], event["status_before"], user_id=user_id) + tracker.update_status(app["id"], event["status_before"], user_id=user_id, source="undo") tracker.update_email_event(user_id, event_id, decision="undone") return tracker.get_email_event(user_id, event_id) diff --git a/api/app/tracker.py b/api/app/tracker.py index fecf08b..c02122e 100644 --- a/api/app/tracker.py +++ b/api/app/tracker.py @@ -55,6 +55,19 @@ ); """ +# Every status an application has been at. source: you, autopilot, email, or earlier (rebuilt from what was stored +# before this log began, so its dates are the best known rather than the moment it happened). +_CREATE_STATUS_HISTORY_TABLE = """ +CREATE TABLE IF NOT EXISTS status_history ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + application_id TEXT NOT NULL, + status TEXT NOT NULL, + source TEXT NOT NULL, + changed_at TEXT NOT NULL +); +""" + _CREATE_INDEX = """ CREATE UNIQUE INDEX IF NOT EXISTS idx_dedup ON applications(user_id, lower(company), lower(job_title)); @@ -156,6 +169,8 @@ def init_db(db_path: str = "") -> None: _gmail_tables(con) _suggestion_tables(con) con.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_autopilot_job ON autopilot_runs(user_id, job_id)") + con.execute(_CREATE_STATUS_HISTORY_TABLE) + con.execute("CREATE INDEX IF NOT EXISTS idx_status_history_user ON status_history(user_id, changed_at)") # Migrations — safe to run on existing DBs for stmt in [ "ALTER TABLE users ADD COLUMN persona TEXT DEFAULT '{}'", @@ -181,6 +196,7 @@ def init_db(db_path: str = "") -> None: con.execute(_CREATE_INDEX) except sqlite3.OperationalError: pass + _start_status_history(con) con.commit() con.close() logger.info("Tracker DB initialised at %s", _DB_PATH) @@ -392,6 +408,31 @@ def set_onboarding_complete(user_id: str) -> None: con.close() +def _log_status(con: sqlite3.Connection, user_id: str, app_id: str, status: str, source: str, at: str) -> None: + con.execute("INSERT INTO status_history (id, user_id, application_id, status, source, changed_at) VALUES (?,?,?,?,?,?)", + (uuid.uuid4().hex, user_id, app_id, status, source, at)) + + +def _start_status_history(con: sqlite3.Connection) -> None: + """Applications from before the log began get what can be rebuilt: their applied date, the status changes their + emails made, and where they are now.""" + apps = con.execute("SELECT a.id, a.user_id, a.status, a.date_applied, a.created_at FROM applications a WHERE a.user_id " + "IS NOT NULL AND NOT EXISTS (SELECT 1 FROM status_history h WHERE h.application_id = a.id)").fetchall() + for app_id, user_id, status, applied, created in apps: + steps = [("Applied", applied, "earlier")] if applied else [] + steps += [(proposed, received, "email") for proposed, received in con.execute( + "SELECT proposed, received_at FROM email_events WHERE application_id=? AND user_id=? AND proposed IS NOT NULL " + "AND decision IN ('applied', 'confirmed')", (app_id, user_id))] + steps.sort(key=lambda step: step[1]) + if not steps or steps[-1][0] != status: + steps.append((status, steps[-1][1] if steps else created, "earlier")) + previous = None + for step, at, source in steps: + if step != previous: + _log_status(con, user_id, app_id, step, source, at) + previous = step + + def add_application( user_id: str, job: dict, @@ -400,11 +441,15 @@ def add_application( score_data: dict, tailored_cv: str = "", status: str = "Pending Review", + *, + source: str = "you", + at: str | None = None, ) -> str | None: """ Insert a new application row for a specific user. Returns the new id, or None if this (company, job_title) already exists for this user. score_data: {composite_score, exposure_score, fit_score, skill_gaps, level_match} + at: when it reached `status`, if that wasn't now (the date of the email that says so). """ if status not in VALID_STATUSES: status = "Pending Review" @@ -418,10 +463,10 @@ def add_application( con.execute( """INSERT INTO applications (id, user_id, job_id, job_title, company, location, job_url, - status, cv_profile, cover_letter, tailored_cv, + status, date_applied, cv_profile, cover_letter, tailored_cv, composite_score, exposure_score, fit_score, skill_gaps, level_match, created_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( app_id, user_id, @@ -431,6 +476,7 @@ def add_application( job.get("location", ""), job.get("url", ""), status, + (at or _now())[:10] if status == "Applied" else None, json.dumps(cv_profile), cover_letter, tailored_cv, @@ -442,6 +488,7 @@ def add_application( _now(), ), ) + _log_status(con, user_id, app_id, status, source, at or _now()) con.commit() logger.info("Tracker: added application %s for user %s (%s @ %s)", app_id, user_id, job.get("title"), job.get("company")) return app_id @@ -482,12 +529,18 @@ def list_applications(user_id: str, status: str | None = None) -> list[dict]: rows = con.execute( "SELECT * FROM applications WHERE user_id=? ORDER BY created_at DESC", (user_id,) ).fetchall() + history: dict[str, list[dict]] = {} + for step in con.execute("SELECT application_id, status, source, changed_at FROM status_history WHERE user_id=? " + "ORDER BY changed_at, rowid", (user_id,)): + history.setdefault(step["application_id"], []).append( + {"status": step["status"], "source": step["source"], "changed_at": step["changed_at"]}) con.close() result = [] for row in rows: d = dict(row) d["skill_gaps"] = json.loads(d.get("skill_gaps") or "[]") d["questions"] = json.loads(d.get("questions") or "[]") + d["history"] = history.get(d["id"], []) # Don't expose full CV/cover letter in list view d.pop("cv_profile", None) d.pop("cover_letter", None) @@ -509,27 +562,47 @@ def find_application_id(user_id: str, company: str, job_title: str) -> str | Non return row[0] if row else None -def update_status(app_id: str, status: str, notes: str | None = None, *, user_id: str) -> bool: - """Update one of `user_id`'s applications. False if the status is invalid or no such row - belongs to that user (so ids from other accounts can't be modified).""" +_BEFORE_APPLYING = ("Saved", "Evaluated", "Pending Review") + + +def update_status(app_id: str, status: str, notes: str | None = None, *, user_id: str, source: str = "you", + at: str | None = None) -> bool: + """Update one of `user_id`'s applications, logging the change. False if the status is invalid or no such row + belongs to that user (so ids from other accounts can't be modified). + at: when it reached `status`, if that wasn't now. source "undo" takes the last change back out of the log, since + it never really happened. The applied date is the day it was first applied: saving a note or moving back to + Applied keeps it.""" if status not in VALID_STATUSES: logger.warning("Tracker: invalid status %r", status) return False try: con = sqlite3.connect(_DB_PATH) + row = con.execute("SELECT status, date_applied FROM applications WHERE id=? AND user_id=?", + (app_id, user_id)).fetchone() + if not row: + con.close() + return False + previous, applied = row updates = ["status=?"] params: list = [status] - if status == "Applied": + if status == "Applied" and (not applied or previous in _BEFORE_APPLYING): updates.append("date_applied=?") - params.append(_now()[:10]) # date only + params.append((at or _now())[:10]) # date only if notes is not None: updates.append("notes=?") params.append(notes) params += [app_id, user_id] - cur = con.execute(f"UPDATE applications SET {', '.join(updates)} WHERE id=? AND user_id=?", params) + con.execute(f"UPDATE applications SET {', '.join(updates)} WHERE id=? AND user_id=?", params) + if status != previous: + if source == "undo": + con.execute("DELETE FROM status_history WHERE id = (SELECT id FROM status_history WHERE application_id=? " + "AND user_id=? AND status=? ORDER BY changed_at DESC, rowid DESC LIMIT 1)", + (app_id, user_id, previous)) + else: + _log_status(con, user_id, app_id, status, source, at or _now()) con.commit() con.close() - return cur.rowcount == 1 + return True except Exception as exc: logger.error("Tracker update_status failed: %s", exc) return False diff --git a/api/tests/test_applications.py b/api/tests/test_applications.py index 167645d..0264148 100644 --- a/api/tests/test_applications.py +++ b/api/tests/test_applications.py @@ -1,4 +1,6 @@ """Application tracker: ownership, and 'Applied' only on a confirmed submission.""" +import sqlite3 + import pytest from app import agent, main, net_guard, tracker @@ -77,3 +79,80 @@ async def collect(): assert final_event({"action": "Cancelled", "done": True})["submitted"] is False assert final_event({"action": "ok", "done": True, "submitted": True})["submitted"] is True + + +# ── Status history ──────────────────────────────────────────────────────────────────────────── + +def _history(user_id, app_id): + app = next(a for a in tracker.list_applications(user_id) if a["id"] == app_id) + return [(s["status"], s["source"]) for s in app["history"]] + + +def _app(user_id, app_id): + return next(a for a in tracker.list_applications(user_id) if a["id"] == app_id) + + +def test_every_status_change_is_logged_with_who_made_it(user): + app_id = tracker.add_application(user, JOB, {}, "", SCORES, status="Applied", source="autopilot", + at="2026-08-02T10:00:00+00:00") + tracker.update_status(app_id, "Interview", user_id=user, source="email", at="2026-09-02T10:00:00+00:00") + tracker.update_status(app_id, "Rejected", user_id=user) + assert _history(user, app_id) == [("Applied", "autopilot"), ("Interview", "email"), ("Rejected", "you")] + assert _app(user, app_id)["history"][1]["changed_at"] == "2026-09-02T10:00:00+00:00" + + +def test_saving_a_note_logs_nothing_and_keeps_the_applied_date(user): + app_id = tracker.add_application(user, JOB, {}, "", SCORES, status="Applied", at="2026-01-05T09:00:00+00:00") + assert tracker.update_status(app_id, "Applied", "Spoke to Sam", user_id=user) + app = _app(user, app_id) + assert app["date_applied"] == "2026-01-05" and app["notes"] == "Spoke to Sam" + assert _history(user, app_id) == [("Applied", "you")] + + +def test_going_back_to_applied_keeps_the_day_it_was_applied(user): + app_id = tracker.add_application(user, JOB, {}, "", SCORES, status="Applied", at="2026-01-05T09:00:00+00:00") + tracker.update_status(app_id, "Interview", user_id=user) + tracker.update_status(app_id, "Applied", user_id=user) + assert _app(user, app_id)["date_applied"] == "2026-01-05" + + +def test_applying_again_after_moving_a_card_back_takes_the_new_date(user): + app_id = tracker.add_application(user, JOB, {}, "", SCORES, status="Applied", at="2026-01-05T09:00:00+00:00") + tracker.update_status(app_id, "Saved", user_id=user) + tracker.update_status(app_id, "Applied", user_id=user, at="2026-03-09T09:00:00+00:00") + assert _app(user, app_id)["date_applied"] == "2026-03-09" + + +def test_an_undone_change_leaves_the_history(user): + app_id = tracker.add_application(user, JOB, {}, "", SCORES, status="Applied") + tracker.update_status(app_id, "Rejected", user_id=user, source="email") + tracker.update_status(app_id, "Applied", user_id=user, source="undo") + assert _history(user, app_id) == [("Applied", "you")] + + +def test_another_users_update_logs_nothing(make_user): + alice, mallory = make_user("alice@x.com"), make_user("mallory@x.com") + app_id = tracker.add_application(alice, JOB, {}, "", SCORES, status="Applied") + assert not tracker.update_status(app_id, "Rejected", user_id=mallory) + assert _history(alice, app_id) == [("Applied", "you")] + + +def test_applications_from_before_the_log_get_what_can_be_rebuilt(user, tmp_path): + db = str(tmp_path / "applications.db") + earlier = tracker.add_application(user, JOB, {}, "", SCORES, status="Applied", at="2026-02-01T00:00:00+00:00") + saved = tracker.add_application(user, {**JOB, "title": "Analyst"}, {}, "", SCORES, status="Saved") + tracker.update_status(earlier, "Rejected", user_id=user) + tracker.add_email_event(user, {"gmail_id": "m1", "received_at": "2026-03-01T00:00:00+00:00", "sender": "a", "subject": "b", + "kind": "interview", "application_id": earlier, "proposed": "Interview", "decision": "applied"}) + tracker.add_email_event(user, {"gmail_id": "m2", "received_at": "2026-03-02T00:00:00+00:00", "sender": "a", "subject": "b", + "kind": "rejected", "application_id": earlier, "proposed": "Rejected", "decision": "undone"}) + con = sqlite3.connect(db) + con.execute("DELETE FROM status_history") + con.commit() + con.close() + + tracker.init_db(db) + tracker.init_db(db) # and only once + + assert _history(user, earlier) == [("Applied", "earlier"), ("Interview", "email"), ("Rejected", "earlier")] + assert _history(user, saved) == [("Saved", "earlier")] diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts index a03e6fe..27b423d 100644 --- a/frontend/e2e/smoke.spec.ts +++ b/frontend/e2e/smoke.spec.ts @@ -212,6 +212,31 @@ test("changing a status in the application panel saves it, and Esc closes the pa api.expectAllMocked(); }); +test("applications show the last 6 months until you widen the range, with how each one got here", async ({ page }) => { + await signIn(page); + const daysAgo = (n: number) => new Date(Date.now() - n * 86400000).toISOString(); + const api = await mockApi(page, { + ...SHELL, + "GET /api/applications": [ + app("new", "Applied", { date_applied: daysAgo(10).slice(0, 10), history: [ + { status: "Applied", source: "email", changed_at: daysAgo(10) }, + ] }), + app("old", "Applied", { date_applied: daysAgo(300).slice(0, 10) }), + ], + }); + await page.goto("/applications"); + await expect(page.getByRole("button", { name: /Role new at Company new/ })).toBeVisible(); + await expect(page.getByRole("button", { name: /Role old at Company old/ })).toHaveCount(0); + await expect(page.getByText("1 older not shown", { exact: false })).toBeVisible(); + + await page.getByRole("group", { name: "Applied within" }).getByRole("button", { name: "1 year" }).click(); + await expect(page.getByRole("button", { name: /Role old at Company old/ })).toBeVisible(); + + await page.getByRole("button", { name: /Role new at Company new/ }).click(); + await expect(page.getByRole("dialog", { name: "Application details" }).getByText("Applied from an email")).toBeVisible(); + api.expectAllMocked(); +}); + test("the review queue lists paused applications and records one you sent yourself", async ({ page }) => { await signIn(page); const patches: unknown[] = []; diff --git a/frontend/src/app/applications/page.tsx b/frontend/src/app/applications/page.tsx index 65b94d9..f3cf5e4 100644 --- a/frontend/src/app/applications/page.tsx +++ b/frontend/src/app/applications/page.tsx @@ -18,6 +18,13 @@ import type { Application } from "@/lib/types"; type View = "board" | "table"; type Quick = "review" | "follow" | "quiet"; +type Range = "6m" | "1y" | "2y" | "all"; + +const RANGES: { key: Range; label: string; days: number }[] = [ + { key: "6m", label: "6 months", days: 182 }, { key: "1y", label: "1 year", days: 365 }, + { key: "2y", label: "2 years", days: 730 }, { key: "all", label: "All", days: Infinity }, +]; +const SOURCE: Record = { autopilot: "by autopilot", email: "from an email" }; const FOLLOW_UP_DAYS = 7; const QUIET_DAYS = 14; @@ -90,6 +97,7 @@ export default function ApplicationsPage() { const [query, setQuery] = useState(""); const [showClosed, setShowClosed] = useState(false); const [quick, setQuick] = useState(null); + const [range, setRange] = useState("6m"); const [openId, setOpenId] = useState(null); const [dragOver, setDragOver] = useState(null); const [error, setError] = useState(null); @@ -99,8 +107,13 @@ export default function ApplicationsPage() { const [savingNotes, setSavingNotes] = useState(false); const [notesSaved, setNotesSaved] = useState(false); - const all = useMemo(() => apps.data ?? [], [apps.data]); - const open = all.find((a) => a.id === openId) ?? null; + const everything = useMemo(() => apps.data ?? [], [apps.data]); + const open = everything.find((a) => a.id === openId) ?? null; + const all = useMemo(() => { + const days = RANGES.find((r) => r.key === range)!.days; + return everything.filter((a) => (daysSince(a.date_applied ?? a.created_at) ?? 0) <= days); + }, [everything, range]); + const older = everything.length - all.length; useEffect(() => { if (open) { @@ -169,12 +182,13 @@ export default function ApplicationsPage() {

Applications

- {activeCount} active, {closedCount} closed.{view === "board" && all.length > 0 ? " Drag a card to change its status." : ""} + {activeCount} active, {closedCount} closed{older > 0 ? `, ${older} older not shown` : ""}.{view === "board" && all.length > 0 ? " Drag a card to change its status." : ""}

+ @@ -213,7 +227,7 @@ export default function ApplicationsPage() {
) : apps.error ? ( {apps.error} - ) : all.length === 0 ? ( + ) : everything.length === 0 ? ( Browse matches}> No applications yet. Save a job from the feed or let the agent apply, and it shows up here. @@ -346,13 +360,13 @@ export default function ApplicationsPage() {