Editable stage history, auto-ghosting, and an Add-form fix
Stage history editor: - The edit form's Pipeline section is now the application's history: one row per step (stage + date), Add Step and Remove Step, kept in date order. The last step is the current stage and the first step's date the date applied, replacing the separate Stage and Date Applied fields. An application logged after the fact (applied, interviewed, rejected) keeps its whole path. - JobsDatabase::replaceStageHistory() rewrites a job's history in one transaction (sorted, repeats collapsed, stage and date applied kept in step); StageHistoryModel backs the section; JobsModel::addJob() now returns the new id so a new application's history can be saved. - `kareer history <id> [Stage=YYYY-MM-DD ...]` shows or replaces it. Auto-ghosting: - Applications still at Applied with no activity (the later of the date applied and the last stage change) for more than 30 days move to Ghosted, recorded like any stage change. Runs at startup for GUI and CLI, after switching databases, and shortly after the setting changes. - Preferences gains an Applications section: on/off and the number of days (kareerrc [AutoGhost]). The GUI shows a passive notification; the CLI notes it on stderr so --json output stays clean. Fixes: - The Add Application form pre-filled empty text fields with the word "undefined" (typing "a" gave "undefineda"): fields with no value reached QML as undefined. Every field now gets a typed default. - Embed the app icon as the window icon fallback, so the window and About page show it when running uninstalled. Tests: history replacement and the after-the-fact Allstate case, the ghosting rules (fresh/stale/logged-late/reopened/threshold), and empty new-form fields. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01BxDf7HqD1xPnsZP8wt3NTk
This commit is contained in:
@@ -42,16 +42,20 @@ Configuring installs a clang-format git pre-commit hook via ECM if `clang-format
|
||||
|
||||
**Persistence.** `JobsDatabase` is the only class that touches SQLite. Two tables: `jobs` and `stage_history`. Schema is created with `CREATE TABLE IF NOT EXISTS` in `migrate()`; there is no version number, so schema changes need an idempotent migration step there. Each `JobsDatabase` instance opens its own uniquely named `QSqlDatabase` connection, so `JobsModel`, `StatsModel` and `SankeyModel` each hold their own instance on the same file. Consequence: after a write through one model, the others do not know; QML wires this up (`DashboardPage` listens to `JobsModel.countChanged` and calls `statsModel.refresh()` and the Sankey `refresh()`).
|
||||
|
||||
**Stage changes must go through `setStage`.** `updateJob` deliberately does not write the stage column. `addJob` records a synthetic `Start -> stage` transition and `setStage` records every move, and that history is the sole input to the Sankey diagram. Bypassing `setStage` silently corrupts the pipeline view. The same applies to the GUI: `JobEditModel::save()` calls `updateJob` and then `setStage` when the stage combo changed (it once skipped the latter, silently dropping stage edits).
|
||||
**Stage changes go through the history.** `updateJob` deliberately does not write the stage column. `addJob` records a synthetic `Start -> stage` transition, `setStage` records a single move, and `replaceStageHistory` rewrites a job's whole history in one transaction (sorted by time, consecutive repeats collapsed; it also sets `jobs.stage` to the last step and `date_applied` to the first step's date). That history is the sole input to the Sankey diagram, so never write `jobs.stage` any other way. The GUI edits history rather than a stage field: `StageHistoryModel` backs the edit form's Pipeline section, and `JobEditModel::save()` derives stage/date applied from it and calls `replaceStageHistory` for new jobs and for edited histories. `kareer history <id> [Stage=YYYY-MM-DD ...]` is the CLI equivalent.
|
||||
|
||||
**Auto-ghosting.** `JobsDatabase::ghostStaleApplications(days)` moves jobs still at Applied whose last activity (the later of `date_applied` and their latest `stage_history` entry) is more than `days` old to Ghosted via `setStage`, so it is recorded like any move. `AutoGhost` (QML singleton; `kareerrc` `[AutoGhost] Enabled/Days`, default on/30) runs it at startup for GUI and CLI in `main.cpp`, again from `Main.qml` after a database switch, and ~1.5 s after the Preferences setting settles; `ran(count)` makes `Main.qml` refresh and show a passive notification.
|
||||
|
||||
**Stage vocabulary.** `JobStage` (`jobstage.h`) is a closed, fixed list (Applied, Screening, Interview, Onsite, Offer, Accepted, Rejected, Withdrawn, Ghosted, plus the synthetic `Start`). It also owns each stage's Sankey column, in-column stacking order, color, and terminal flag. Input is canonicalized case-insensitively on write. Adding a stage touches this file, the migration's canonicalization loop, and the README.
|
||||
|
||||
**Sankey layout.** `SankeyModel::reload()` turns `stage_history` into one left-to-right path per job (Start, the funnel stages it reached in increasing column order, then its current stage if terminal), so backward/sideways moves never become ribbons and node values equal the number of jobs that reached them. `relayout()` computes all geometry, including SVG path strings for `QtQuick.Shapes` `PathSvg`; `SankeyDiagram.qml` only draws the `nodes`/`links` lists. Layout is lane-based: links to Rejected/Withdrawn/Ghosted are drop-offs that travel in under-lanes below each column's node; main-line links that skip a column travel in over-lanes above it; each column stack is top-aligned and the whole block is centered. Every ribbon crossing a given gap between columns uses the same x endpoints, so ribbons can only cross if their vertical order differs at the two ends of a gap; the lane/slot orderings are chosen to keep it equal everywhere except the final gap into the outcome nodes. `reload()` re-reads the DB; `relayout()` recomputes geometry from cached counts (used on resize via a debounce timer). `autotests/sankeylayouttest.cpp` checks this on the drawn geometry (it parses `pathData` and asserts ribbons never overlap) and points `KAREER_DB_PATH` at a temp file because `SankeyModel` always opens the default path.
|
||||
|
||||
**Edit form.** The add/edit form is data-driven rather than hand-written per field: `JobFieldCatalog` is the static list of categories and fields (id, label, row type, combo options, spin range); `JobEditModel` is a `QAbstractListModel` with one row per field holding the current values, and `ApplicationEditPage.qml` renders it with a `DelegateChooser` on `rowType`. Field ids must match the keys `JobsModel::mapFromJob` / `jobFromMap` use. Adding a form field means: `Job` struct, `JobsDatabase` columns and `jobFromQuery`, `JobsModel` roles and map conversion, `JobFieldCatalog`, and the CLI options.
|
||||
**Edit form.** The add/edit form is data-driven rather than hand-written per field: `JobFieldCatalog` is the static list of categories and fields (id, label, row type, combo options, spin range); `JobEditModel` is a `QAbstractListModel` with one row per field holding the current values, and `ApplicationEditPage.qml` renders it with a `DelegateChooser` on `rowType`. Field ids must match the keys `JobsModel::mapFromJob` / `jobFromMap` use. The `pipeline` category deliberately has no catalog fields; `ApplicationEditPage.qml` renders `JobEditModel::history` there instead. Adding a form field means: `Job` struct, `JobsDatabase` columns and `jobFromQuery`, `JobsModel` roles and map conversion, `JobFieldCatalog`, and the CLI options.
|
||||
|
||||
**Window layout.** `Main.qml` uses a two-column `pageStack`: `ApplicationsPage` (sidebar list) is fixed, and the second column is swapped between `DashboardPage` and `ApplicationEditPage` with `pageStack.replace`. A single `JobsModel` instance is created in `Main.qml` and passed down as a property.
|
||||
|
||||
**App icon.** `icons/` holds the hicolor set named after the app ID (`sc-apps-io.github.toservetheking.Kareer.svg` plus PNGs rendered from it); the desktop file, `setDesktopFileName`, and Flatpak's icon export all depend on that exact name. The SVG is also embedded (`qt_add_resources` in `src/CMakeLists.txt`) as the window-icon fallback for uninstalled runs. Keep the SVG to plain shapes and gradients: QtSvg ignores filters.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Every source file starts with an SPDX header (`GPL-3.0-or-later` for code; docs like README/CONTRIBUTING are CC0). Files that cannot carry one are listed in `REUSE.toml`. CI fails on missing headers.
|
||||
|
||||
Reference in New Issue
Block a user