Author SHA1 Message Date
austinandClaude Opus 5 fc9928aef8 v0.1.5: editable stage history, auto-ghosting, new icon
Lint / REUSE compliance (push) Successful in 3s
Lint / clang-format (push) Failing after 4s
Build and Test / Build and run tests (push) Failing after 2s
Build and Publish Flatpak / Build Flatpak (push) Failing after 2s
Build and Publish Flatpak / Deploy hosted repo to Pages (push) Skipped
- Edit an application's full stage history, with a date per step, so an
  application logged after the fact keeps its whole path
- `kareer history` to show or replace a stage history
- Mark applications with no response as Ghosted after 30 days,
  adjustable or off in Preferences
- New app icon, installed under the app ID so launchers and the Flatpak
  show it
- Fix the Add Application form filling empty fields with "undefined"
- Bump the version, add the 0.1.5 metainfo release, and update the
  manifest's pinned tag

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01BxDf7HqD1xPnsZP8wt3NTk
2026-09-11 17:01:38 -05:00
austinandClaude Opus 5 eb47a589cb 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
2026-09-11 17:00:45 -05:00
austinandClaude Opus 5 289246a6d0 New app icon: a K drawn as the pipeline splitting
The stem is every application, splitting into a green success path
rising to an outcome and a drop-off falling away, on a Breeze-blue
rounded square. Plain shapes and gradients only, so QtSvg renders it
exactly; PNGs from 16 to 128 px are rendered from the SVG.

The icon set is now named after the app ID (io.github.toservetheking.
Kareer). It was installed as "kareer", which nothing looked up: the
desktop file and the window ask for the app ID, so launchers fell back
to a generic icon, and Flatpak (which only exports icons prefixed with
the app ID) shipped none.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01BxDf7HqD1xPnsZP8wt3NTk
2026-09-11 17:00:30 -05:00
austinandClaude Opus 5 1f1c628ee4 dist/arch: update PKGBUILD to 0.1.4
Lint / REUSE compliance (push) Successful in 3s
Build and Test / Build and run tests (push) Failing after 1s
Lint / clang-format (push) Failing after 4s
sha256 of GitHub's v0.1.4 archive, verified stable across two downloads.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01BxDf7HqD1xPnsZP8wt3NTk
2026-09-11 16:18:33 -05:00
austinandClaude Opus 5 76c6e05ee0 v0.1.4: fix Save, un-braid the pipeline, choose the database location
Lint / REUSE compliance (push) Successful in 3s
Lint / clang-format (push) Failing after 5s
Build and Test / Build and run tests (push) Failing after 2s
Build and Publish Flatpak / Build Flatpak (push) Failing after 3s
Build and Publish Flatpak / Deploy hosted repo to Pages (push) Skipped
- Fix Save on the edit form silently dropping stage changes; report a
  missing company or title instead of saving a blank field
- Rework the pipeline Sankey: drop-offs split off each stage in level
  lanes and rejoin their outcome without braiding
- Count each application once as a forward path, so back moves and
  ghosted-then-rejected no longer draw extra ribbons
- First-run choice of database location (default, a folder, or an
  existing file used in place), plus a Database section in Preferences
- --db <path> for the GUI and every subcommand
- Bump the version, add the 0.1.4 metainfo release, and update the
  manifest's pinned tag

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01BxDf7HqD1xPnsZP8wt3NTk
2026-09-11 16:14:35 -05:00
45 changed files with 1182 additions and 70 deletions
+6 -2
View File
@@ -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.
+1 -1
View File
@@ -5,7 +5,7 @@
cmake_minimum_required(VERSION 3.16)
project(kareer VERSION 0.1.3 LANGUAGES CXX)
project(kareer VERSION 0.1.5 LANGUAGES CXX)
set(REQUIRED_QT_VERSION 6.6.0)
set(REQUIRED_KF_VERSION 6.5.0)
+18 -4
View File
@@ -15,12 +15,18 @@ applications without ever opening a window.
salary expectation, notes, contact, and the date applied
- A fixed pipeline of stages (Applied, Screening, Interview, Onsite,
Offer, Accepted, Rejected, Withdrawn, Ghosted) with full history of
every transition
every transition. The history is editable, with a date per step, so an
application you log after the fact (applied, interviewed, rejected)
keeps its whole path
- A Sankey diagram of the whole pipeline, showing where applications
progress and where they drop off
- Dashboard summary stats: total applications, active count, offers,
response rate
- A full CLI (`kareer add|list|show|update|stage|delete|stats|stages`)
- Applications with no response are marked Ghosted automatically:
anything still at Applied with no activity for 30 days (adjustable, or
off, under Preferences). The move is recorded in its history like any
other, and the CLI notes it on stderr so `--json` output stays clean.
- A full CLI (`kareer add|list|show|update|stage|history|delete|stats|stages`)
for scripting
## Command line usage
@@ -49,6 +55,10 @@ kareer show 1
kareer update 1 --salary-max 175000
kareer stage 1 Interview
# Show or rewrite an application's stage history, one Stage=date per step
kareer history 1
kareer history 1 Applied=2026-08-01 Interview=2026-08-15 Rejected=2026-08-20
# Delete (requires --yes to actually happen)
kareer delete 1 --yes
@@ -169,10 +179,14 @@ Run the tests with `ctest --test-dir build`.
draws what this hands back.
- `jobfieldcatalog.{h,cpp}` - the static catalog of edit-form fields and categories.
- `jobeditmodel.{h,cpp}` - `QAbstractListModel`-backed edit-form state, built from the field catalog.
- `stagehistorymodel.{h,cpp}` - the editable stage history shown in the edit form's Pipeline section.
- `clicommands.{h,cpp}` - the `add`/`list`/`show`/`update`/`stage`/
`delete`/`stats`/`stages` subcommands.
`history`/`delete`/`stats`/`stages` subcommands.
- `appcolorscheme.{h,cpp}` - QML-facing wrapper around `KColorSchemeManager` for the Preferences page.
- `databaselocation.{h,cpp}` - where the database lives: the first-run choice and the Preferences Database section.
- `autoghost.{h,cpp}` - marks applications with no response as Ghosted, and its Preferences setting.
- `qml/` - Kirigami UI: `ApplicationsPage` (list + search),
`ApplicationEditPage` (add/edit/delete form), `DashboardPage`
(stat cards + pipeline), `SankeyDiagram` (the renderer),
`SettingsPage` (the Preferences page).
`SettingsPage` (the Preferences page), `DatabaseSetupDialog`
(the first-run database choice).
+1
View File
@@ -46,6 +46,7 @@ add_executable(jobeditmodeltest
../src/jobsdatabase.cpp
../src/jobsmodel.cpp
../src/jobeditmodel.cpp
../src/stagehistorymodel.cpp
../src/jobfieldcatalog.cpp
)
+66 -1
View File
@@ -43,6 +43,12 @@ private Q_SLOTS:
QCOMPARE(valueOf(edit, u"company"_s).toString(), u"Acme Corp"_s);
QCOMPARE(valueOf(edit, u"title"_s).toString(), u"Engineer"_s);
QCOMPARE(valueOf(edit, u"salaryMin"_s).toInt(), 100000);
// The history's first step shows the date applied, not when it was logged.
StageHistoryModel *history = edit.history();
QCOMPARE(history->rowCount(), 1);
QCOMPARE(history->data(history->index(0), StageHistoryModel::StageRole).toString(), u"Applied"_s);
QCOMPARE(history->data(history->index(0), StageHistoryModel::DateRole).toDate(), QDate(2026, 6, 1));
}
void savesEditsToExistingJob()
@@ -79,17 +85,55 @@ private Q_SLOTS:
edit.setEditingJobId(id);
edit.setJobsModel(&jobs);
setValueOf(edit, u"stage"_s, u"Interview"_s);
// Moving the application on is adding a step to its history.
StageHistoryModel *history = edit.history();
history->appendStep();
history->setStage(history->rowCount() - 1, u"Interview"_s);
QVERIFY2(edit.save(), qPrintable(edit.lastError()));
JobsDatabase db;
QCOMPARE(db.jobById(id)->stage, u"Interview"_s);
QCOMPARE(db.jobById(id)->dateApplied, QDate(2026, 6, 1));
const auto transitions = db.stageTransitions();
QCOMPARE(transitions.size(), 2);
QCOMPARE(transitions.last().fromStage, u"Applied"_s);
QCOMPARE(transitions.last().toStage, u"Interview"_s);
}
// Applied, interviewed, then rejected: logged in one go after the fact.
void logsApplicationAfterTheFact()
{
JobsModel jobs;
JobEditModel edit;
edit.setJobsModel(&jobs);
setValueOf(edit, u"company"_s, u"Allstate"_s);
setValueOf(edit, u"title"_s, u"Software Engineer"_s);
StageHistoryModel *history = edit.history();
const auto day = [](int month, int dayOfMonth) {
return QDateTime(QDate(2026, month, dayOfMonth), QTime(9, 0));
};
history->setDate(0, day(8, 1));
history->appendStep();
history->setStage(1, u"Interview"_s);
history->setDate(1, day(8, 15));
history->appendStep();
history->setStage(2, u"Rejected"_s);
history->setDate(2, day(8, 20));
QVERIFY2(edit.save(), qPrintable(edit.lastError()));
JobsDatabase db;
const QList<Job> all = db.allJobs();
QCOMPARE(all.size(), 1);
QCOMPARE(all.first().stage, u"Rejected"_s);
QCOMPARE(all.first().dateApplied, QDate(2026, 8, 1));
const QList<StageStep> steps = db.stageHistory(all.first().id);
QCOMPARE(steps.size(), 3);
QCOMPARE(steps.at(1).stage, u"Interview"_s);
QCOMPARE(steps.at(1).at.toLocalTime().date(), QDate(2026, 8, 15));
QCOMPARE(steps.at(2).at.toLocalTime().date(), QDate(2026, 8, 20));
}
void addsNewJob()
{
JobsModel jobs;
@@ -108,6 +152,27 @@ private Q_SLOTS:
QCOMPARE(all.first().stage, u"Applied"_s);
}
// A new form's text fields must start empty; a missing value showed up
// in QML as the word "undefined".
void newFormFieldsStartEmpty()
{
JobsModel jobs;
JobEditModel edit;
edit.setJobsModel(&jobs);
for (int row = 0; row < edit.rowCount(); ++row) {
const QVariant value = edit.data(edit.index(row), JobEditModel::ValueRole);
const QString id = edit.data(edit.index(row), JobEditModel::FieldIdRole).toString();
QVERIFY2(value.isValid(), qPrintable(id));
}
for (const QString &id : {u"company"_s, u"title"_s, u"location"_s, u"source"_s, u"url"_s, u"contact"_s, u"notes"_s}) {
const QVariant value = valueOf(edit, id);
QCOMPARE(value.typeId(), QMetaType::QString);
QVERIFY2(value.toString().isEmpty(), qPrintable(id));
}
QCOMPARE(valueOf(edit, u"currency"_s).toString(), u"USD"_s);
QCOMPARE(valueOf(edit, u"remoteType"_s).toString(), u"Unspecified"_s);
}
void missingCompanyIsReported()
{
JobsModel jobs;
+146
View File
@@ -7,6 +7,8 @@
#include "jobsdatabase.h"
#include "job.h"
#include <QSqlDatabase>
#include <QSqlQuery>
#include <QTemporaryDir>
#include <QtTest>
@@ -144,6 +146,150 @@ private Q_SLOTS:
QVERIFY(!db.jobById(job.id).has_value());
QVERIFY(db.stageTransitions().isEmpty());
}
void ghostsStaleApplications()
{
QTemporaryDir dir;
const QString path = dbPathIn(dir);
JobsDatabase db(path);
const QDateTime now(QDate(2026, 9, 11), QTime(12, 0), QTimeZone::UTC);
const auto daysAgo = [&](int days) {
return now.addDays(-days);
};
// Still at Applied, nothing for 40 days: ghosted.
const int stale = addJob(db, {});
backdate(path, stale, {daysAgo(40)}, daysAgo(40).date());
// Applied 10 days ago: too recent.
const int fresh = addJob(db, {});
backdate(path, fresh, {daysAgo(10)}, daysAgo(10).date());
// Got a response (Screening) 40 days ago: not ours to ghost.
const int screening = addJob(db, {QStringLiteral("Screening")});
backdate(path, screening, {daysAgo(45), daysAgo(40)}, daysAgo(45).date());
// Applied 60 days ago but only logged 5 days ago: the month starts at logging.
const int loggedLate = addJob(db, {});
backdate(path, loggedLate, {daysAgo(5)}, daysAgo(60).date());
// Logged 40 days ago, date applied later edited to 10 days ago.
const int appliedLater = addJob(db, {});
backdate(path, appliedLater, {daysAgo(40)}, daysAgo(10).date());
// Ghosted before, then moved back to Applied 5 days ago: a fresh month.
const int reopened = addJob(db, {QStringLiteral("Ghosted"), QStringLiteral("Applied")});
backdate(path, reopened, {daysAgo(60), daysAgo(50), daysAgo(5)}, daysAgo(60).date());
QCOMPARE(db.ghostStaleApplications(0, now), 0);
QCOMPARE(db.ghostStaleApplications(30, now), 1);
QCOMPARE(db.jobById(stale)->stage, QStringLiteral("Ghosted"));
for (int id : {fresh, loggedLate, appliedLater, reopened}) {
QCOMPARE(db.jobById(id)->stage, QStringLiteral("Applied"));
}
QCOMPARE(db.jobById(screening)->stage, QStringLiteral("Screening"));
// Recorded like any other move, so the pipeline shows it.
bool recorded = false;
for (const StageTransition &t : db.stageTransitions()) {
recorded |= t.jobId == stale && t.fromStage == QStringLiteral("Applied") && t.toStage == QStringLiteral("Ghosted");
}
QVERIFY(recorded);
// Nothing left to do on a second run; a shorter threshold catches more.
QCOMPARE(db.ghostStaleApplications(30, now), 0);
QCOMPARE(db.ghostStaleApplications(7, now), 2); // fresh and appliedLater, both 10 days
}
// Logging an application after the fact: Applied, Interview, Rejected.
void replacesStageHistory()
{
QTemporaryDir dir;
JobsDatabase db(dbPathIn(dir));
const int id = addJob(db, {});
const auto at = [](int month, int day) {
return QDateTime(QDate(2026, month, day), QTime(12, 0));
};
// Out of order and with a repeated stage, as an editor might hand it over.
QVERIFY2(db.replaceStageHistory(id,
{{QStringLiteral("rejected"), at(8, 20)},
{QStringLiteral("Applied"), at(8, 1)},
{QStringLiteral("Interview"), at(8, 15)},
{QStringLiteral("Interview"), at(8, 16)}}),
qPrintable(db.lastError()));
const QList<StageStep> steps = db.stageHistory(id);
QCOMPARE(steps.size(), 3);
QCOMPARE(steps.at(0).stage, QStringLiteral("Applied"));
QCOMPARE(steps.at(1).stage, QStringLiteral("Interview"));
QCOMPARE(steps.at(2).stage, QStringLiteral("Rejected"));
QCOMPARE(steps.at(1).at.toLocalTime().date(), QDate(2026, 8, 15));
const auto job = db.jobById(id);
QCOMPARE(job->stage, QStringLiteral("Rejected"));
QCOMPARE(job->dateApplied, QDate(2026, 8, 1));
// The moves chain from the synthetic Start, so the pipeline sees the path.
QStringList moves;
for (const StageTransition &t : db.stageTransitions()) {
if (t.jobId == id) {
moves.append((t.fromStage.isEmpty() ? QStringLiteral("Start") : t.fromStage) + QStringLiteral(">") + t.toStage);
}
}
QCOMPARE(moves, (QStringList{QStringLiteral("Start>Applied"), QStringLiteral("Applied>Interview"), QStringLiteral("Interview>Rejected")}));
// Bad input changes nothing.
QVERIFY(!db.replaceStageHistory(id, {}));
QVERIFY(!db.replaceStageHistory(id, {{QStringLiteral("Applied"), at(8, 1)}, {QStringLiteral("Bogus"), at(8, 2)}}));
QVERIFY(!db.replaceStageHistory(id, {{QStringLiteral("Applied"), QDateTime()}}));
QVERIFY(!db.replaceStageHistory(9999, {{QStringLiteral("Applied"), at(8, 1)}}));
QCOMPARE(db.stageHistory(id).size(), 3);
QCOMPARE(db.jobById(id)->stage, QStringLiteral("Rejected"));
}
private:
static int addJob(JobsDatabase &db, const QStringList &stages)
{
Job job;
job.company = QStringLiteral("Co");
job.title = QStringLiteral("Title");
if (!db.addJob(job)) {
return -1;
}
for (const QString &stage : stages) {
db.setStage(job.id, stage);
}
return job.id;
}
/// Rewrites a job's history timestamps (oldest first, one per recorded
/// move) and its date applied, through a separate connection.
static void backdate(const QString &path, int jobId, const QList<QDateTime> &history, const QDate &applied)
{
{
QSqlDatabase raw = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), QStringLiteral("backdate"));
raw.setDatabaseName(path);
QVERIFY(raw.open());
QSqlQuery ids(raw);
QVERIFY(ids.exec(QStringLiteral("SELECT id FROM stage_history WHERE job_id = %1 ORDER BY id").arg(jobId)));
QList<int> rows;
while (ids.next()) {
rows.append(ids.value(0).toInt());
}
QCOMPARE(rows.size(), history.size());
QSqlQuery update(raw);
for (int i = 0; i < rows.size(); ++i) {
update.prepare(QStringLiteral("UPDATE stage_history SET changed_at = ? WHERE id = ?"));
update.addBindValue(history.at(i).toString(Qt::ISODate));
update.addBindValue(rows.at(i));
QVERIFY(update.exec());
}
update.prepare(QStringLiteral("UPDATE jobs SET date_applied = ?, created_at = ? WHERE id = ?"));
update.addBindValue(applied.toString(Qt::ISODate));
update.addBindValue(history.first().toString(Qt::ISODate));
update.addBindValue(jobId);
QVERIFY(update.exec());
raw.close();
}
QSqlDatabase::removeDatabase(QStringLiteral("backdate"));
}
};
QTEST_GUILESS_MAIN(JobsDatabaseTest)
+2 -2
View File
@@ -3,7 +3,7 @@
# Maintainer: Austin Bennett <austin at thebennett dot net>
pkgname=kareer
pkgver=0.1.3
pkgver=0.1.4
pkgrel=1
pkgdesc="Job application tracker with a Sankey pipeline view and a scriptable CLI (Kirigami)"
arch=('x86_64')
@@ -29,7 +29,7 @@ makedepends=(
)
checkdepends=(appstream)
source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
sha256sums=('7c30d4ebf2375d151e5132a57ce5b51ae7772d78a798ffa62a640114d8ff1486')
sha256sums=('e73e7a552bca68fc3f869c96efc607f0862564b3e5f99a505ff33b618bc5cb71')
build() {
cmake -B build -S "Kareer-$pkgver" \
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 572 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 825 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

+10 -8
View File
@@ -2,15 +2,17 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
# Named after the app ID, as the desktop file, the window icon and Flatpak
# (which only exports icons prefixed with the app ID) all expect.
ecm_install_icons(ICONS
sc-apps-kareer.svg
128-apps-kareer.png
64-apps-kareer.png
48-apps-kareer.png
44-apps-kareer.png
32-apps-kareer.png
22-apps-kareer.png
16-apps-kareer.png
sc-apps-io.github.toservetheking.Kareer.svg
128-apps-io.github.toservetheking.Kareer.png
64-apps-io.github.toservetheking.Kareer.png
48-apps-io.github.toservetheking.Kareer.png
44-apps-io.github.toservetheking.Kareer.png
32-apps-io.github.toservetheking.Kareer.png
22-apps-io.github.toservetheking.Kareer.png
16-apps-io.github.toservetheking.Kareer.png
DESTINATION ${KDE_INSTALL_ICONDIR}
THEME hicolor
)
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]> -->
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!--
A K for Kareer, drawn as its application pipeline: the stem is every
application, splitting into a success path rising to an outcome and a
drop-off falling away. Plain shapes and gradients only, so QtSvg (which
ignores filters) renders it exactly as other renderers do.
-->
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="backdrop" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#4cbcf2"/>
<stop offset="1" stop-color="#1c6ea9"/>
</linearGradient>
<linearGradient id="success" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#ffffff"/>
<stop offset="1" stop-color="#6ff0a0"/>
</linearGradient>
<linearGradient id="dropoff" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.92"/>
<stop offset="1" stop-color="#ffffff" stop-opacity="0.3"/>
</linearGradient>
</defs>
<!-- shadow, rounded-square backdrop, and a soft highlight on its top half -->
<rect x="10" y="12" width="108" height="108" rx="24" fill="#0b2a44" fill-opacity="0.28"/>
<rect x="10" y="10" width="108" height="108" rx="24" fill="url(#backdrop)"/>
<rect x="10" y="10" width="108" height="54" rx="24" fill="#ffffff" fill-opacity="0.06"/>
<!-- the arms of the K: success rising, drop-off falling -->
<path d="M41,44 C67,44 67,20 95,20 L95,40 C67,40 67,64 41,64 Z" fill="url(#success)"/>
<path d="M41,64 C67,64 67,88 95,88 L95,108 C67,108 67,84 41,84 Z" fill="url(#dropoff)"/>
<!-- the stem (all applications) and the two outcomes -->
<rect x="27" y="20" width="14" height="88" rx="4" fill="#ffffff"/>
<rect x="95" y="20" width="6" height="20" rx="2" fill="#4be07a"/>
<rect x="95" y="88" width="6" height="20" rx="2" fill="#ffffff" fill-opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

-26
View File
@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]> -->
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#63d4c7"/>
<stop offset="1" stop-color="#1a7a70"/>
</linearGradient>
</defs>
<!-- rounded-square backdrop -->
<rect x="8" y="8" width="112" height="112" rx="28" ry="28" fill="url(#bg)"/>
<!-- briefcase -->
<g>
<rect x="34" y="58" width="60" height="42" rx="6" fill="#ffffff" fill-opacity="0.92"/>
<rect x="52" y="46" width="24" height="14" rx="4" fill="none" stroke="#ffffff" stroke-opacity="0.92" stroke-width="6"/>
<rect x="34" y="72" width="60" height="10" fill="#1a7a70" fill-opacity="0.35"/>
<rect x="60" y="68" width="8" height="10" rx="2" fill="#1a7a70" fill-opacity="0.55"/>
</g>
<!-- upward trend line, representing progress through the pipeline -->
<polyline points="30,44 46,30 58,38 74,22 90,30" fill="none" stroke="#ffffff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<polygon points="90,30 98,28 92,36" fill="#ffffff"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

@@ -65,6 +65,29 @@
<content_rating type="oars-1.1"/>
<releases>
<release version="0.1.5" date="2026-09-11">
<description>
<ul>
<li>Edit an application's full stage history, with a date for each step, so an application you log after the fact keeps its whole path (for example applied, interviewed, rejected)</li>
<li>Add a history command to show or replace a stage history from the command line</li>
<li>Mark applications with no response as Ghosted after 30 days, adjustable or off in Preferences</li>
<li>New app icon, now installed under the app ID so launchers and the Flatpak show it</li>
<li>Fix the Add Application form filling empty fields with the word "undefined"</li>
</ul>
</description>
</release>
<release version="0.1.4" date="2026-09-11">
<description>
<ul>
<li>Fix Save on the edit form silently dropping stage changes, and report missing company or title</li>
<li>Rework the pipeline diagram so drop-offs split off each stage and rejoin their outcome without braiding</li>
<li>Count each application once in the pipeline, so moving back a stage or being ghosted then rejected no longer draws extra ribbons</li>
<li>Choose where the database lives on first run: the default location, a folder of your choice, or an existing database</li>
<li>Move, open or reset the database location from Preferences</li>
<li>Add a --db option to use a specific database file from the command line or the GUI</li>
</ul>
</description>
</release>
<release version="0.1.3" date="2026-08-31">
<description>
<ul>
+1 -1
View File
@@ -39,5 +39,5 @@ modules:
# For Flathub / release builds, pin to a tag AND commit instead:
# - type: git
# url: https://github.com/toservetheking/Kareer.git
# tag: v0.1.1
# tag: v0.1.5
# commit: <fill in the exact commit SHA the tag points at>
+12
View File
@@ -31,10 +31,22 @@ qt_add_qml_module(kareer
sankeymodel.h
jobeditmodel.cpp
jobeditmodel.h
stagehistorymodel.cpp
stagehistorymodel.h
appcolorscheme.cpp
appcolorscheme.h
databaselocation.cpp
databaselocation.h
autoghost.cpp
autoghost.h
)
# The app icon, embedded so the window and About page show it even when
# running from the build directory; the installed hicolor icon wins when present.
qt_add_resources(kareer "appicon"
PREFIX "/icons"
BASE "${CMAKE_SOURCE_DIR}/icons"
FILES "${CMAKE_SOURCE_DIR}/icons/sc-apps-io.github.toservetheking.Kareer.svg"
)
target_include_directories(kareer PRIVATE ${CMAKE_BINARY_DIR})
+92
View File
@@ -0,0 +1,92 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "autoghost.h"
#include "jobsdatabase.h"
#include <KConfigGroup>
#include <KSharedConfig>
using namespace Qt::Literals::StringLiterals;
namespace
{
int s_lastRunCount = 0;
KConfigGroup settingsGroup()
{
return KConfigGroup(KSharedConfig::openConfig(u"kareerrc"_s), u"AutoGhost"_s);
}
}
AutoGhost::AutoGhost(QObject *parent)
: QObject(parent)
{
m_settleTimer.setSingleShot(true);
m_settleTimer.setInterval(1500);
connect(&m_settleTimer, &QTimer::timeout, this, &AutoGhost::run);
}
int AutoGhost::runNow()
{
const KConfigGroup group = settingsGroup();
if (!group.readEntry("Enabled", true)) {
s_lastRunCount = 0;
return 0;
}
JobsDatabase db;
s_lastRunCount = db.ghostStaleApplications(group.readEntry("Days", DefaultDays));
return s_lastRunCount;
}
bool AutoGhost::enabled() const
{
return settingsGroup().readEntry("Enabled", true);
}
void AutoGhost::setEnabled(bool enabled)
{
if (enabled == this->enabled()) {
return;
}
KConfigGroup group = settingsGroup();
group.writeEntry("Enabled", enabled);
group.sync();
Q_EMIT settingsChanged();
m_settleTimer.start();
}
int AutoGhost::days() const
{
return settingsGroup().readEntry("Days", DefaultDays);
}
void AutoGhost::setDays(int days)
{
days = qMax(1, days);
if (days == this->days()) {
return;
}
KConfigGroup group = settingsGroup();
group.writeEntry("Days", days);
group.sync();
Q_EMIT settingsChanged();
m_settleTimer.start();
}
int AutoGhost::lastRunCount() const
{
return s_lastRunCount;
}
int AutoGhost::run()
{
m_settleTimer.stop();
const int count = runNow();
Q_EMIT ran(count);
return count;
}
+61
View File
@@ -0,0 +1,61 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once
#include <QObject>
#include <QQmlEngine>
#include <QTimer>
/**
* Marks applications with no response as Ghosted: anything still at
* Applied whose last activity is more than days() old (see
* JobsDatabase::ghostStaleApplications()). The switch and the threshold live
* in kareerrc ([AutoGhost] Enabled/Days) and are shown on the Preferences
* page.
*
* runNow() is called at startup for both the GUI and the CLI; the GUI runs
* it again after switching databases and shortly after the settings change,
* and refreshes the models when ran() reports moved applications.
*/
class AutoGhost : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
Q_PROPERTY(bool enabled READ enabled WRITE setEnabled NOTIFY settingsChanged)
Q_PROPERTY(int days READ days WRITE setDays NOTIFY settingsChanged)
/// How many applications the most recent run moved to Ghosted.
Q_PROPERTY(int lastRunCount READ lastRunCount NOTIFY ran)
public:
static constexpr int DefaultDays = 30;
explicit AutoGhost(QObject *parent = nullptr);
/// Ghosts stale applications in the current database if enabled, and
/// returns how many were moved.
static int runNow();
bool enabled() const;
void setEnabled(bool enabled);
int days() const;
void setDays(int days);
int lastRunCount() const;
/// runNow(), then emits ran() with the number of applications moved.
Q_INVOKABLE int run();
Q_SIGNALS:
void settingsChanged();
void ran(int count);
private:
/// Settings changes run shortly after they settle, so stepping the
/// threshold down doesn't ghost applications at every value in between.
QTimer m_settleTimer;
};
+74 -1
View File
@@ -540,6 +540,74 @@ int runStats(const QString &program, const QStringList &args)
return 0;
}
int runHistory(const QString &program, const QStringList &args)
{
QCommandLineParser parser;
parser.setApplicationDescription(
u"Show an application's stage history, or replace it with the given steps, e.g.\n"
u" kareer history 3 Applied=2026-08-01 Interview=2026-08-15 Rejected=2026-08-20\n"
u"The last step becomes the current stage and the first step's date the date applied."_s);
parser.addHelpOption();
addDbOption(parser);
parser.addOption({u"json"_s, u"Print machine-readable JSON"_s});
parser.addPositionalArgument(u"id"_s, u"Application id"_s);
parser.addPositionalArgument(u"steps"_s, u"Optional: Stage=YYYY-MM-DD for each step, replacing the history"_s, u"[steps...]"_s);
parser.process(QStringList{program} + args);
QTextStream err(stderr);
const QStringList positional = parser.positionalArguments();
if (positional.isEmpty()) {
err << u"Error: usage: kareer history <id> [Stage=YYYY-MM-DD ...]"_s << Qt::endl;
return 1;
}
bool ok = false;
const int id = positional.first().toInt(&ok);
if (!ok) {
err << u"Error: id must be an integer"_s << Qt::endl;
return 1;
}
JobsDatabase db;
if (!db.jobById(id)) {
err << u"Error: no application #%1"_s.arg(id) << Qt::endl;
return 1;
}
if (positional.size() > 1) {
QList<StageStep> steps;
for (const QString &arg : positional.mid(1)) {
const qsizetype separator = arg.indexOf(u'=');
const QString stage = arg.left(separator);
const QDate date = QDate::fromString(arg.mid(separator + 1), Qt::ISODate);
if (separator < 0 || !JobStage::isValid(stage) || !date.isValid()) {
err << u"Error: '%1' is not Stage=YYYY-MM-DD. Valid stages: %2"_s.arg(arg, JobStage::canonicalStages().join(u", "_s)) << Qt::endl;
return 1;
}
// Midday local time, so the calendar date survives time-zone conversion.
steps.append({stage, QDateTime(date, QTime(12, 0))});
}
if (!db.replaceStageHistory(id, steps)) {
err << u"Error: %1"_s.arg(db.lastError()) << Qt::endl;
return 1;
}
}
const QList<StageStep> steps = db.stageHistory(id);
if (parser.isSet(u"json"_s)) {
QJsonArray array;
for (const StageStep &step : steps) {
array.append(QJsonObject{{u"stage"_s, step.stage}, {u"date"_s, step.at.toLocalTime().date().toString(Qt::ISODate)}});
}
printJson(array);
} else {
QTextStream out(stdout);
for (const StageStep &step : steps) {
out << step.at.toLocalTime().date().toString(Qt::ISODate) << u" "_s << step.stage << Qt::endl;
}
}
return 0;
}
int runStages(const QString &program, const QStringList &args)
{
QCommandLineParser parser;
@@ -571,7 +639,8 @@ int runHelp()
out << u"Usage: kareer <command> [options]\n\n"_s << u"Commands:\n"_s << u" add Add a new job application\n"_s
<< u" list List job applications\n"_s << u" show Show one job application\n"_s << u" update Update fields on an existing application\n"_s
<< u" stage Move an application to a new stage\n"_s << u" delete Delete an application\n"_s << u" stats Summary statistics\n"_s
<< u" stages List the canonical pipeline stages\n\n"_s << u"Run 'kareer <command> --help' for the options of a specific command.\n"_s
<< u" stages List the canonical pipeline stages\n"_s << u" history Show or replace an application's stage history\n\n"_s
<< u"Run 'kareer <command> --help' for the options of a specific command.\n"_s
<< u"Running kareer with no command (or an unrecognized one) starts the GUI.\n\n"_s << u"Global options:\n"_s
<< u" --db <path> Use this database file (created if missing) instead of the configured one\n"_s;
return 0;
@@ -590,6 +659,7 @@ bool Cli::isSubcommand(const QString &arg)
u"delete"_s,
u"stats"_s,
u"stages"_s,
u"history"_s,
u"help"_s,
};
return subcommands.contains(arg);
@@ -626,5 +696,8 @@ int Cli::run(QCoreApplication &app)
if (subcommand == u"stages"_s) {
return runStages(program, rest);
}
if (subcommand == u"history"_s) {
return runHistory(program, rest);
}
return runHelp();
}
+1 -1
View File
@@ -11,7 +11,7 @@
class QCoreApplication;
/**
* Headless command-line interface: `kareer add|list|show|update|stage|delete|stats|stages ...`.
* Headless command-line interface: `kareer add|list|show|update|stage|history|delete|stats|stages ...`.
* Lets other tools (a resume generator, a shell script) log and query
* applications without ever starting the Kirigami GUI.
*/
+6
View File
@@ -31,6 +31,12 @@ struct Job {
QDateTime updatedAt;
};
/// One step in an application's history: the stage it moved to, and when.
struct StageStep {
QString stage;
QDateTime at;
};
/// One recorded move from one stage to another (or from "Start" for the
/// initial application), used to build the Sankey diagram.
struct StageTransition {
+53 -12
View File
@@ -29,6 +29,7 @@ bool isSalaryField(const QString &id)
JobEditModel::JobEditModel(QObject *parent)
: QAbstractListModel(parent)
, m_history(new StageHistoryModel(this))
, m_fields(JobFieldCatalog::fields())
{
resetValues();
@@ -123,12 +124,30 @@ void JobEditModel::setEditingJobId(int id)
void JobEditModel::resetValues()
{
m_values.clear();
m_loadedStage.clear();
if (m_editingJobId < 0 || !m_jobsModel) {
m_history->load({{u"Applied"_s, QDateTime(QDate::currentDate(), QTime(12, 0)).toUTC()}});
// Every field needs a value of its own type: a missing one reaches
// QML as undefined, which a text field shows as the word "undefined"
// (typing "a" then gave "undefineda").
for (const Field &field : m_fields) {
switch (field.rowType) {
case TextRow:
case TextAreaRow:
m_values[field.id] = QString();
break;
case ComboRow:
m_values[field.id] = field.comboOptions.value(0);
break;
case SpinBoxRow:
m_values[field.id] = 0;
break;
case DateRow:
m_values[field.id] = QDate::currentDate();
break;
}
}
m_values[u"currency"_s] = u"USD"_s;
m_values[u"stage"_s] = u"Applied"_s;
m_values[u"dateApplied"_s] = QDate::currentDate();
m_values[u"remoteType"_s] = u"Unspecified"_s;
m_values[u"salaryMin"_s] = 0;
m_values[u"salaryMax"_s] = 0;
@@ -145,7 +164,18 @@ void JobEditModel::resetValues()
}
m_values[field.id] = value;
}
m_loadedStage = m_values.value(u"stage"_s).toString();
QList<StageStep> steps = m_jobsModel->stageHistory(m_editingJobId);
if (steps.isEmpty()) {
steps.append({data.value(u"stage"_s).toString(), data.value(u"createdAt"_s).toDateTime()});
}
// The first step's recorded time is when the job was logged; show the
// date actually applied instead, which is what that step stands for.
const QDate applied = data.value(u"dateApplied"_s).toDate();
if (applied.isValid()) {
steps.first().at = QDateTime(applied, QTime(12, 0)).toUTC();
}
m_history->load(steps);
}
if (rowCount() > 0) {
@@ -167,6 +197,11 @@ QString JobEditModel::lastError() const
return m_lastError;
}
StageHistoryModel *JobEditModel::history() const
{
return m_history;
}
void JobEditModel::setValue(int row, const QVariant &value)
{
if (row < 0 || row >= m_fields.size()) {
@@ -207,28 +242,34 @@ bool JobEditModel::save()
}
}
// The history decides the current stage and the date applied.
const QList<StageStep> steps = m_history->steps();
fields[u"stage"_s] = steps.last().stage;
fields[u"dateApplied"_s] = steps.first().at.toLocalTime().date();
if (m_editingJobId < 0) {
if (!m_jobsModel->addJob(fields)) {
const int id = m_jobsModel->addJob(fields);
// A new application's history is always written in full, so one
// logged after the fact (Applied, Interview, Rejected) keeps its path.
if (id < 0 || !m_jobsModel->replaceStageHistory(id, steps)) {
setLastError(m_jobsModel->lastError());
return false;
}
return true;
}
// updateJob() deliberately never writes the stage; stage changes only
// ever come from the history.
if (!m_jobsModel->updateJob(m_editingJobId, fields)) {
setLastError(m_jobsModel->lastError());
return false;
}
// updateJob() deliberately never writes the stage (so every stage change
// lands in stage_history); route a changed stage through setStage().
const QString stage = fields.value(u"stage"_s).toString();
if (!stage.isEmpty() && stage != m_loadedStage) {
if (!m_jobsModel->setStage(m_editingJobId, stage)) {
if (m_history->isEdited()) {
if (!m_jobsModel->replaceStageHistory(m_editingJobId, steps)) {
setLastError(m_jobsModel->lastError());
return false;
}
m_loadedStage = stage;
m_history->load(m_jobsModel->stageHistory(m_editingJobId));
}
return true;
}
+6 -1
View File
@@ -8,6 +8,7 @@
#include "jobfieldcatalog.h"
#include "jobsmodel.h"
#include "stagehistorymodel.h"
#include <QAbstractListModel>
#include <QHash>
@@ -29,6 +30,9 @@ class JobEditModel : public QAbstractListModel
Q_PROPERTY(int editingJobId READ editingJobId WRITE setEditingJobId NOTIFY editingJobIdChanged)
Q_PROPERTY(QVariantList categories READ categories CONSTANT)
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
/// The application's stage history (the Pipeline section): the last step
/// is its current stage, the first step's date its date applied.
Q_PROPERTY(StageHistoryModel *history READ history CONSTANT)
public:
enum Roles {
@@ -58,6 +62,7 @@ public:
QVariantList categories() const;
QString lastError() const;
StageHistoryModel *history() const;
/// Updates the value for the field at this row (called from the QML delegate).
Q_INVOKABLE void setValue(int row, const QVariant &value);
@@ -80,6 +85,6 @@ private:
int m_editingJobId = -1;
QHash<QString, QVariant> m_values;
QString m_lastError;
QString m_loadedStage; ///< Stage as loaded from the database, to detect stage changes on save.
StageHistoryModel *m_history = nullptr;
QList<JobFieldCatalog::Field> m_fields;
};
+3 -3
View File
@@ -5,7 +5,6 @@
*/
#include "jobfieldcatalog.h"
#include "jobstage.h"
using namespace Qt::Literals::StringLiterals;
@@ -37,8 +36,9 @@ QList<Field> fields()
0,
0},
{u"stage"_s, u"pipeline"_s, QStringLiteral("Stage:"), ComboRow, JobStage::canonicalStages(), {}, 0, 0},
{u"dateApplied"_s, u"pipeline"_s, QStringLiteral("Date Applied:"), DateRow, {}, {}, 0, 0},
// The "pipeline" category has no plain fields: ApplicationEditPage
// shows JobEditModel::history there (stage and date applied come
// from the history's last and first steps).
{u"salaryMin"_s, u"salary"_s, QStringLiteral("Range Minimum:"), SpinBoxRow, {}, {}, 0, 5000000},
{u"salaryMax"_s, u"salary"_s, QStringLiteral("Range Maximum:"), SpinBoxRow, {}, {}, 0, 5000000},
+142
View File
@@ -479,6 +479,148 @@ bool JobsDatabase::deleteJob(int id)
return true;
}
QList<StageStep> JobsDatabase::stageHistory(int jobId) const
{
QList<StageStep> steps;
if (!isOpen()) {
return steps;
}
QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(u"SELECT to_stage, changed_at FROM stage_history WHERE job_id = :id ORDER BY changed_at ASC, id ASC"_s);
query.bindValue(u":id"_s, jobId);
if (!query.exec()) {
return steps;
}
while (query.next()) {
steps.append({query.value(0).toString(), QDateTime::fromString(query.value(1).toString(), Qt::ISODate)});
}
return steps;
}
bool JobsDatabase::replaceStageHistory(int jobId, QList<StageStep> steps)
{
if (!jobById(jobId)) {
m_lastError = u"No job with id %1"_s.arg(jobId);
return false;
}
if (steps.isEmpty()) {
m_lastError = u"An application needs at least one stage"_s;
return false;
}
for (StageStep &step : steps) {
if (!JobStage::isValid(step.stage)) {
m_lastError = u"Unknown stage '%1'"_s.arg(step.stage);
return false;
}
if (!step.at.isValid()) {
m_lastError = u"Missing date for stage '%1'"_s.arg(step.stage);
return false;
}
step.stage = JobStage::canonical(step.stage);
}
std::stable_sort(steps.begin(), steps.end(), [](const StageStep &a, const StageStep &b) {
return a.at < b.at;
});
QList<StageStep> collapsed;
for (const StageStep &step : std::as_const(steps)) {
if (collapsed.isEmpty() || collapsed.last().stage != step.stage) {
collapsed.append(step);
}
}
QSqlDatabase db = QSqlDatabase::database(m_connectionName);
if (!db.transaction()) {
m_lastError = db.lastError().text();
return false;
}
const auto fail = [&](const QSqlQuery &query) {
m_lastError = query.lastError().text();
db.rollback();
return false;
};
QSqlQuery query(db);
query.prepare(u"DELETE FROM stage_history WHERE job_id = :id"_s);
query.bindValue(u":id"_s, jobId);
if (!query.exec()) {
return fail(query);
}
QString previous;
for (const StageStep &step : std::as_const(collapsed)) {
query.prepare(u"INSERT INTO stage_history (job_id, from_stage, to_stage, changed_at) VALUES (:job_id, :from_stage, :to_stage, :changed_at)"_s);
query.bindValue(u":job_id"_s, jobId);
query.bindValue(u":from_stage"_s, previous.isEmpty() ? QVariant() : QVariant(previous));
query.bindValue(u":to_stage"_s, step.stage);
query.bindValue(u":changed_at"_s, step.at.toUTC().toString(Qt::ISODate));
if (!query.exec()) {
return fail(query);
}
previous = step.stage;
}
query.prepare(u"UPDATE jobs SET stage = :stage, date_applied = :date_applied, updated_at = :updated_at WHERE id = :id"_s);
query.bindValue(u":stage"_s, collapsed.last().stage);
query.bindValue(u":date_applied"_s, collapsed.first().at.toLocalTime().date().toString(Qt::ISODate));
query.bindValue(u":updated_at"_s, QDateTime::currentDateTimeUtc().toString(Qt::ISODate));
query.bindValue(u":id"_s, jobId);
if (!query.exec()) {
return fail(query);
}
if (!db.commit()) {
m_lastError = db.lastError().text();
db.rollback();
return false;
}
return true;
}
int JobsDatabase::ghostStaleApplications(int days, const QDateTime &now)
{
if (days <= 0 || !isOpen()) {
return 0;
}
QList<int> stale;
{
QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(
uR"(
SELECT j.id, j.date_applied, j.created_at, MAX(h.changed_at)
FROM jobs j LEFT JOIN stage_history h ON h.job_id = j.id
WHERE j.stage = :stage
GROUP BY j.id
)"_s);
query.bindValue(u":stage"_s, u"Applied"_s);
if (!query.exec()) {
m_lastError = query.lastError().text();
return 0;
}
while (query.next()) {
// A job logged today for an application sent weeks ago only
// starts its month today, and one moved back to Applied gets a
// fresh month: whichever happened last counts.
QDateTime lastActivity = QDateTime::fromString(query.value(3).toString(), Qt::ISODate);
if (!lastActivity.isValid()) {
lastActivity = QDateTime::fromString(query.value(2).toString(), Qt::ISODate);
}
const QDate applied = QDate::fromString(query.value(1).toString(), Qt::ISODate);
if (applied.isValid() && (!lastActivity.isValid() || applied.startOfDay() > lastActivity)) {
lastActivity = applied.startOfDay();
}
if (lastActivity.isValid() && lastActivity.addDays(days) < now) {
stale.append(query.value(0).toInt());
}
}
}
int moved = 0;
for (int id : std::as_const(stale)) {
if (setStage(id, u"Ghosted"_s)) {
++moved;
}
}
return moved;
}
QList<StageTransition> JobsDatabase::stageTransitions() const
{
QList<StageTransition> transitions;
+17
View File
@@ -87,6 +87,23 @@ public:
bool deleteJob(int id);
/// The steps one application went through, oldest first.
QList<StageStep> stageHistory(int jobId) const;
/// Rewrites an application's whole history in one transaction (used by
/// the history editor, e.g. to log Applied -> Interview -> Rejected after
/// the fact). Steps are sorted by time (stably), consecutive repeats of a
/// stage collapse into the first, the job's stage becomes the last step's
/// and its date applied the first step's date. Fails, changing nothing,
/// if there are no steps, a stage is unknown, or a time is invalid.
bool replaceStageHistory(int jobId, QList<StageStep> steps);
/// Moves every application still at Applied whose last activity (the
/// later of its date applied and its last stage change) is more than
/// `days` days before `now` to Ghosted, recorded through setStage() like
/// any other move. Returns how many were moved; does nothing if days <= 0.
int ghostStaleApplications(int days, const QDateTime &now = QDateTime::currentDateTimeUtc());
QList<StageTransition> stageTransitions() const;
private:
+19 -5
View File
@@ -167,14 +167,14 @@ QVariantMap JobsModel::jobData(int id) const
return mapFromJob(*job);
}
bool JobsModel::addJob(const QVariantMap &fields)
int JobsModel::addJob(const QVariantMap &fields)
{
Job job = jobFromMap(fields);
const bool ok = m_db.addJob(job);
if (ok) {
refresh();
if (!m_db.addJob(job)) {
return -1;
}
return ok;
refresh();
return job.id;
}
bool JobsModel::updateJob(int id, const QVariantMap &fields)
@@ -206,6 +206,20 @@ bool JobsModel::removeJob(int id)
return ok;
}
QList<StageStep> JobsModel::stageHistory(int id) const
{
return m_db.stageHistory(id);
}
bool JobsModel::replaceStageHistory(int id, const QList<StageStep> &steps)
{
const bool ok = m_db.replaceStageHistory(id, steps);
if (ok) {
refresh();
}
return ok;
}
QString JobsModel::lastError() const
{
return m_db.lastError();
+5 -1
View File
@@ -60,11 +60,15 @@ public:
/// Full record for one job, for prefilling the edit dialog.
Q_INVOKABLE QVariantMap jobData(int id) const;
Q_INVOKABLE bool addJob(const QVariantMap &fields);
/// Adds a job and returns its id, or -1 on failure (see lastError()).
Q_INVOKABLE int addJob(const QVariantMap &fields);
Q_INVOKABLE bool updateJob(int id, const QVariantMap &fields);
Q_INVOKABLE bool setStage(int id, const QString &stage);
Q_INVOKABLE bool removeJob(int id);
QList<StageStep> stageHistory(int id) const;
bool replaceStageHistory(int id, const QList<StageStep> &steps);
Q_INVOKABLE QString lastError() const;
public Q_SLOTS:
+9 -1
View File
@@ -6,6 +6,7 @@
#include "kareer-version.h"
#include "autoghost.h"
#include "clicommands.h"
#include "databaselocation.h"
#include "jobsdatabase.h"
@@ -110,6 +111,10 @@ int main(int argc, char *argv[])
if (argc >= 2 && Cli::isSubcommand(QString::fromLocal8Bit(argv[1]))) {
QCoreApplication app(argc, argv);
DatabaseLocation::loadConfiguredPath();
if (const int ghosted = AutoGhost::runNow(); ghosted > 0) {
// stderr, so --json output on stdout stays machine-readable.
fprintf(stderr, "kareer: marked %d application(s) with no response as Ghosted\n", ghosted);
}
return Cli::run(app);
}
@@ -134,7 +139,7 @@ int main(int argc, char *argv[])
aboutData.setDesktopFileName(u"io.github.toservetheking.Kareer"_s);
KAboutData::setApplicationData(aboutData);
QApplication::setWindowIcon(QIcon::fromTheme(u"io.github.toservetheking.Kareer"_s, QIcon::fromTheme(u"office-address-book"_s)));
QApplication::setWindowIcon(QIcon::fromTheme(u"io.github.toservetheking.Kareer"_s, QIcon(u":/icons/sc-apps-io.github.toservetheking.Kareer.svg"_s)));
KCrash::initialize();
@@ -149,6 +154,9 @@ int main(int argc, char *argv[])
// First run (or the configured file has gone missing): open nothing until
// the user picks a location in DatabaseSetupDialog. The CLI never waits.
JobsDatabase::setSelectionPending(DatabaseLocation::needsSetup());
if (!JobsDatabase::selectionPending()) {
AutoGhost::runNow(); // before the models load; Main.qml reports the count
}
QQmlApplicationEngine engine;
KLocalization::setupLocalizedContext(&engine);
+73
View File
@@ -103,7 +103,80 @@ Kirigami.ScrollablePage {
filterString: section.modelData.id
}
// The Pipeline category has no plain fields: it is the
// application's stage history. The last step is its current
// stage, the first step's date its date applied.
ColumnLayout {
visible: section.modelData.id === "pipeline"
Layout.fillWidth: true
Layout.leftMargin: Kirigami.Units.largeSpacing
Layout.rightMargin: Kirigami.Units.largeSpacing
Layout.topMargin: Kirigami.Units.smallSpacing
Layout.bottomMargin: Kirigami.Units.smallSpacing
spacing: Kirigami.Units.smallSpacing
QQC2.Label {
Layout.fillWidth: true
text: i18nc("@info", "Every stage this application went through, oldest first. The first date is when you applied, and the last stage is where it stands now.")
wrapMode: Text.Wrap
font: Kirigami.Theme.smallFont
opacity: 0.7
}
Repeater {
model: section.modelData.id === "pipeline" ? editModel.history : null
delegate: RowLayout {
id: stepRow
required property int index
required property string stage
required property var date
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
QQC2.ComboBox {
Layout.fillWidth: true
model: editModel.history.stages
currentIndex: editModel.history.stages.indexOf(stepRow.stage)
onActivated: editModel.history.setStage(stepRow.index, currentText)
Accessible.name: i18nc("@label:listbox", "Stage")
}
QQC2.Button {
text: Qt.formatDate(stepRow.date, Qt.ISODate)
icon.name: "view-calendar-day"
Accessible.name: i18nc("@action:button", "Date of this stage")
onClicked: {
stepDatePopup.value = stepRow.date;
stepDatePopup.open();
}
DateTime.DatePopup {
id: stepDatePopup
onAccepted: editModel.history.setDate(stepRow.index, value)
}
}
QQC2.ToolButton {
icon.name: "list-remove"
text: i18nc("@action:button", "Remove Step")
display: QQC2.AbstractButton.IconOnly
enabled: editModel.history.count > 1
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
onClicked: editModel.history.removeStep(stepRow.index)
}
}
}
QQC2.Button {
icon.name: "list-add"
text: i18nc("@action:button", "Add Step")
onClicked: editModel.history.appendStep()
}
}
ColumnLayout {
visible: rowRepeater.count > 0
Layout.fillWidth: true
Layout.leftMargin: Kirigami.Units.largeSpacing
Layout.rightMargin: Kirigami.Units.largeSpacing
+19
View File
@@ -31,11 +31,28 @@ Kirigami.ApplicationWindow {
Connections {
target: DatabaseLocation
function onChanged(): void {
AutoGhost.run();
jobsModel.refresh();
root.showDashboard();
}
}
// Applications with no response moved to Ghosted (at startup, after a
// database switch, or after the Preferences setting changed).
Connections {
target: AutoGhost
function onRan(count: int): void {
if (count > 0) {
jobsModel.refresh();
root.announceGhosted(count);
}
}
}
function announceGhosted(count: int): void {
root.showPassiveNotification(i18ncp("@info", "Marked %1 application with no response as Ghosted", "Marked %1 applications with no response as Ghosted", count));
}
DatabaseSetupDialog {
id: setupDialog
}
@@ -43,6 +60,8 @@ Kirigami.ApplicationWindow {
Component.onCompleted: {
if (DatabaseLocation.setupPending) {
setupDialog.open();
} else if (AutoGhost.lastRunCount > 0) {
root.announceGhosted(AutoGhost.lastRunCount);
}
}
+51
View File
@@ -149,6 +149,57 @@ Kirigami.ScrollablePage {
}
}
}
Kirigami.ListSectionHeader {
Layout.fillWidth: true
text: i18nc("@title:group", "Applications")
}
ColumnLayout {
Layout.fillWidth: true
Layout.leftMargin: Kirigami.Units.largeSpacing
Layout.rightMargin: Kirigami.Units.largeSpacing
Layout.topMargin: Kirigami.Units.smallSpacing
Layout.bottomMargin: Kirigami.Units.smallSpacing
spacing: Kirigami.Units.smallSpacing
QQC2.Switch {
id: autoGhostSwitch
Layout.fillWidth: true
text: i18nc("@option:check", "Mark applications with no response as Ghosted")
checked: AutoGhost.enabled
onToggled: AutoGhost.enabled = checked
}
RowLayout {
Layout.fillWidth: true
enabled: autoGhostSwitch.checked
spacing: Kirigami.Units.smallSpacing
QQC2.Label {
text: i18nc("@label:spinbox Mark as ghosted after [N] days", "After")
}
QQC2.SpinBox {
from: 1
to: 365
value: AutoGhost.days
onValueModified: AutoGhost.days = value
}
QQC2.Label {
Layout.fillWidth: true
text: i18nc("@label:spinbox Mark as ghosted after [N] days", "days without any activity")
wrapMode: Text.Wrap
}
}
QQC2.Label {
Layout.fillWidth: true
text: i18nc("@info", "Only applications still at Applied are affected. The move is recorded in their history, and you can change the stage back at any time.")
wrapMode: Text.Wrap
font: Kirigami.Theme.smallFont
opacity: 0.7
}
}
}
Component.onCompleted: DatabaseLocation.clearMessages()
+156
View File
@@ -0,0 +1,156 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "stagehistorymodel.h"
#include "jobstage.h"
#include <algorithm>
using namespace Qt::Literals::StringLiterals;
namespace
{
/// Midday local time, so the calendar date survives time-zone conversion.
QDateTime atMidday(const QDate &date)
{
return QDateTime(date, QTime(12, 0)).toUTC();
}
QString nextStage(const QString &stage)
{
static const QStringList funnel{u"Applied"_s, u"Screening"_s, u"Interview"_s, u"Onsite"_s, u"Offer"_s, u"Accepted"_s};
if (JobStage::isTerminal(stage)) {
return u"Screening"_s; // a closed application reopening
}
const int index = funnel.indexOf(stage);
return funnel.value(index + 1, u"Rejected"_s);
}
}
StageHistoryModel::StageHistoryModel(QObject *parent)
: QAbstractListModel(parent)
{
}
int StageHistoryModel::rowCount(const QModelIndex &parent) const
{
return parent.isValid() ? 0 : m_steps.size();
}
QVariant StageHistoryModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_steps.size()) {
return {};
}
const StageStep &step = m_steps.at(index.row());
switch (role) {
case StageRole:
return step.stage;
case DateRole:
return step.at.toLocalTime().date();
default:
return {};
}
}
QHash<int, QByteArray> StageHistoryModel::roleNames() const
{
return {{StageRole, "stage"}, {DateRole, "date"}};
}
QStringList StageHistoryModel::stages() const
{
return JobStage::canonicalStages();
}
void StageHistoryModel::load(const QList<StageStep> &steps)
{
beginResetModel();
m_steps = steps;
std::stable_sort(m_steps.begin(), m_steps.end(), [](const StageStep &a, const StageStep &b) {
return a.at < b.at;
});
endResetModel();
m_edited = false;
Q_EMIT countChanged();
}
QList<StageStep> StageHistoryModel::steps() const
{
return m_steps;
}
bool StageHistoryModel::isEdited() const
{
return m_edited;
}
void StageHistoryModel::setStage(int row, const QString &stage)
{
if (row < 0 || row >= m_steps.size() || !JobStage::isValid(stage) || m_steps.at(row).stage == stage) {
return;
}
m_steps[row].stage = JobStage::canonical(stage);
Q_EMIT dataChanged(index(row), index(row), {StageRole});
markEdited();
}
void StageHistoryModel::setDate(int row, const QDateTime &when)
{
if (row < 0 || row >= m_steps.size() || !when.isValid()) {
return;
}
const QDate date = when.toLocalTime().date();
if (m_steps.at(row).at.toLocalTime().date() == date) {
return;
}
m_steps[row].at = atMidday(date);
sortByDate();
markEdited();
}
void StageHistoryModel::appendStep()
{
const QString stage = m_steps.isEmpty() ? u"Applied"_s : nextStage(m_steps.last().stage);
// Never earlier than the current last step, so the new step stays last.
QDateTime at = atMidday(QDate::currentDate());
if (!m_steps.isEmpty() && m_steps.last().at > at) {
at = m_steps.last().at;
}
beginInsertRows({}, m_steps.size(), m_steps.size());
m_steps.append({stage, at});
endInsertRows();
Q_EMIT countChanged();
markEdited();
}
void StageHistoryModel::removeStep(int row)
{
if (row < 0 || row >= m_steps.size() || m_steps.size() <= 1) {
return;
}
beginRemoveRows({}, row, row);
m_steps.removeAt(row);
endRemoveRows();
Q_EMIT countChanged();
markEdited();
}
void StageHistoryModel::sortByDate()
{
beginResetModel();
std::stable_sort(m_steps.begin(), m_steps.end(), [](const StageStep &a, const StageStep &b) {
return a.at < b.at;
});
endResetModel();
}
void StageHistoryModel::markEdited()
{
m_edited = true;
Q_EMIT edited();
}
+70
View File
@@ -0,0 +1,70 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once
#include "job.h"
#include <QAbstractListModel>
#include <QQmlEngine>
/**
* The editable stage history of one application, as shown in the edit
* form's Pipeline section: one row per step (stage + date), kept in date
* order. The last step is the application's current stage, and the first
* step's date is its date applied. JobEditModel owns one and saves it with
* JobsDatabase::replaceStageHistory().
*/
class StageHistoryModel : public QAbstractListModel
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Owned by JobEditModel")
Q_PROPERTY(int count READ rowCount NOTIFY countChanged)
Q_PROPERTY(QStringList stages READ stages CONSTANT)
public:
enum Roles {
StageRole = Qt::UserRole + 1,
DateRole,
};
Q_ENUM(Roles)
explicit StageHistoryModel(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role) const override;
QHash<int, QByteArray> roleNames() const override;
QStringList stages() const;
/// Replaces every step without marking the history as edited.
void load(const QList<StageStep> &steps);
QList<StageStep> steps() const;
bool isEdited() const;
Q_INVOKABLE void setStage(int row, const QString &stage);
/// Takes a JS Date from QML; only its (local) calendar date is kept.
Q_INVOKABLE void setDate(int row, const QDateTime &when);
/// Appends a step dated today, defaulting to the stage that usually
/// comes next.
Q_INVOKABLE void appendStep();
/// Removes a step; the last remaining step can't be removed.
Q_INVOKABLE void removeStep(int row);
Q_SIGNALS:
void countChanged();
/// Emitted after any change made through the invokables.
void edited();
private:
void sortByDate();
void markEdited();
QList<StageStep> m_steps;
bool m_edited = false;
};