Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f304850c7a | ||
|
|
ea4a3cc315 | ||
|
|
9b57fc7bcf | ||
|
|
fc9928aef8 | ||
|
|
eb47a589cb | ||
|
|
289246a6d0 | ||
|
|
1f1c628ee4 | ||
|
|
76c6e05ee0 |
@@ -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()`).
|
**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.
|
**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.
|
**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.
|
**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
|
## 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.
|
- 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.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
cmake_minimum_required(VERSION 3.16)
|
cmake_minimum_required(VERSION 3.16)
|
||||||
|
|
||||||
project(kareer VERSION 0.1.3 LANGUAGES CXX)
|
project(kareer VERSION 0.1.6 LANGUAGES CXX)
|
||||||
|
|
||||||
set(REQUIRED_QT_VERSION 6.6.0)
|
set(REQUIRED_QT_VERSION 6.6.0)
|
||||||
set(REQUIRED_KF_VERSION 6.5.0)
|
set(REQUIRED_KF_VERSION 6.5.0)
|
||||||
|
|||||||
@@ -15,12 +15,18 @@ applications without ever opening a window.
|
|||||||
salary expectation, notes, contact, and the date applied
|
salary expectation, notes, contact, and the date applied
|
||||||
- A fixed pipeline of stages (Applied, Screening, Interview, Onsite,
|
- A fixed pipeline of stages (Applied, Screening, Interview, Onsite,
|
||||||
Offer, Accepted, Rejected, Withdrawn, Ghosted) with full history of
|
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
|
- A Sankey diagram of the whole pipeline, showing where applications
|
||||||
progress and where they drop off
|
progress and where they drop off
|
||||||
- Dashboard summary stats: total applications, active count, offers,
|
- Dashboard summary stats: total applications, active count, offers,
|
||||||
response rate
|
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
|
for scripting
|
||||||
|
|
||||||
## Command line usage
|
## Command line usage
|
||||||
@@ -49,6 +55,10 @@ kareer show 1
|
|||||||
kareer update 1 --salary-max 175000
|
kareer update 1 --salary-max 175000
|
||||||
kareer stage 1 Interview
|
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)
|
# Delete (requires --yes to actually happen)
|
||||||
kareer delete 1 --yes
|
kareer delete 1 --yes
|
||||||
|
|
||||||
@@ -169,10 +179,14 @@ Run the tests with `ctest --test-dir build`.
|
|||||||
draws what this hands back.
|
draws what this hands back.
|
||||||
- `jobfieldcatalog.{h,cpp}` - the static catalog of edit-form fields and categories.
|
- `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.
|
- `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`/
|
- `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.
|
- `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),
|
- `qml/` - Kirigami UI: `ApplicationsPage` (list + search),
|
||||||
`ApplicationEditPage` (add/edit/delete form), `DashboardPage`
|
`ApplicationEditPage` (add/edit/delete form), `DashboardPage`
|
||||||
(stat cards + pipeline), `SankeyDiagram` (the renderer),
|
(stat cards + pipeline), `SankeyDiagram` (the renderer),
|
||||||
`SettingsPage` (the Preferences page).
|
`SettingsPage` (the Preferences page), `DatabaseSetupDialog`
|
||||||
|
(the first-run database choice).
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ add_executable(jobeditmodeltest
|
|||||||
../src/jobsdatabase.cpp
|
../src/jobsdatabase.cpp
|
||||||
../src/jobsmodel.cpp
|
../src/jobsmodel.cpp
|
||||||
../src/jobeditmodel.cpp
|
../src/jobeditmodel.cpp
|
||||||
|
../src/stagehistorymodel.cpp
|
||||||
../src/jobfieldcatalog.cpp
|
../src/jobfieldcatalog.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ private Q_SLOTS:
|
|||||||
QCOMPARE(valueOf(edit, u"company"_s).toString(), u"Acme Corp"_s);
|
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"title"_s).toString(), u"Engineer"_s);
|
||||||
QCOMPARE(valueOf(edit, u"salaryMin"_s).toInt(), 100000);
|
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()
|
void savesEditsToExistingJob()
|
||||||
@@ -79,17 +85,55 @@ private Q_SLOTS:
|
|||||||
edit.setEditingJobId(id);
|
edit.setEditingJobId(id);
|
||||||
edit.setJobsModel(&jobs);
|
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()));
|
QVERIFY2(edit.save(), qPrintable(edit.lastError()));
|
||||||
|
|
||||||
JobsDatabase db;
|
JobsDatabase db;
|
||||||
QCOMPARE(db.jobById(id)->stage, u"Interview"_s);
|
QCOMPARE(db.jobById(id)->stage, u"Interview"_s);
|
||||||
|
QCOMPARE(db.jobById(id)->dateApplied, QDate(2026, 6, 1));
|
||||||
const auto transitions = db.stageTransitions();
|
const auto transitions = db.stageTransitions();
|
||||||
QCOMPARE(transitions.size(), 2);
|
QCOMPARE(transitions.size(), 2);
|
||||||
QCOMPARE(transitions.last().fromStage, u"Applied"_s);
|
QCOMPARE(transitions.last().fromStage, u"Applied"_s);
|
||||||
QCOMPARE(transitions.last().toStage, u"Interview"_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()
|
void addsNewJob()
|
||||||
{
|
{
|
||||||
JobsModel jobs;
|
JobsModel jobs;
|
||||||
@@ -108,6 +152,27 @@ private Q_SLOTS:
|
|||||||
QCOMPARE(all.first().stage, u"Applied"_s);
|
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()
|
void missingCompanyIsReported()
|
||||||
{
|
{
|
||||||
JobsModel jobs;
|
JobsModel jobs;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
#include "jobsdatabase.h"
|
#include "jobsdatabase.h"
|
||||||
#include "job.h"
|
#include "job.h"
|
||||||
|
|
||||||
|
#include <QSqlDatabase>
|
||||||
|
#include <QSqlQuery>
|
||||||
#include <QTemporaryDir>
|
#include <QTemporaryDir>
|
||||||
#include <QtTest>
|
#include <QtTest>
|
||||||
|
|
||||||
@@ -144,6 +146,150 @@ private Q_SLOTS:
|
|||||||
QVERIFY(!db.jobById(job.id).has_value());
|
QVERIFY(!db.jobById(job.id).has_value());
|
||||||
QVERIFY(db.stageTransitions().isEmpty());
|
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)
|
QTEST_GUILESS_MAIN(JobsDatabaseTest)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
# Maintainer: Austin Bennett <austin at thebennett dot net>
|
# Maintainer: Austin Bennett <austin at thebennett dot net>
|
||||||
pkgname=kareer
|
pkgname=kareer
|
||||||
pkgver=0.1.3
|
pkgver=0.1.5
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Job application tracker with a Sankey pipeline view and a scriptable CLI (Kirigami)"
|
pkgdesc="Job application tracker with a Sankey pipeline view and a scriptable CLI (Kirigami)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -29,7 +29,7 @@ makedepends=(
|
|||||||
)
|
)
|
||||||
checkdepends=(appstream)
|
checkdepends=(appstream)
|
||||||
source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
|
source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
|
||||||
sha256sums=('7c30d4ebf2375d151e5132a57ce5b51ae7772d78a798ffa62a640114d8ff1486')
|
sha256sums=('bc1011f9a013d8d2b266da0fed7cbb50f7410e71b007844bfb9598639504168c')
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
cmake -B build -S "Kareer-$pkgver" \
|
cmake -B build -S "Kareer-$pkgver" \
|
||||||
|
|||||||
|
After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 758 B |
|
Before Width: | Height: | Size: 572 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 825 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -2,15 +2,25 @@
|
|||||||
#
|
#
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# 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.
|
||||||
|
# Image loaders (gdk-pixbuf, and so AppStream compose in the Flatpak build)
|
||||||
|
# recognise an SVG by finding "<svg" in its first 256 bytes; past that the
|
||||||
|
# file is "Unrecognized image file format" and the Flatpak build fails.
|
||||||
|
file(READ sc-apps-io.github.toservetheking.Kareer.svg _kareer_svg_head LIMIT 256)
|
||||||
|
if(NOT _kareer_svg_head MATCHES "<svg")
|
||||||
|
message(FATAL_ERROR "icons/sc-apps-io.github.toservetheking.Kareer.svg: the <svg> tag must start within the first 256 bytes; move comments inside the element.")
|
||||||
|
endif()
|
||||||
|
|
||||||
ecm_install_icons(ICONS
|
ecm_install_icons(ICONS
|
||||||
sc-apps-kareer.svg
|
sc-apps-io.github.toservetheking.Kareer.svg
|
||||||
128-apps-kareer.png
|
128-apps-io.github.toservetheking.Kareer.png
|
||||||
64-apps-kareer.png
|
64-apps-io.github.toservetheking.Kareer.png
|
||||||
48-apps-kareer.png
|
48-apps-io.github.toservetheking.Kareer.png
|
||||||
44-apps-kareer.png
|
44-apps-io.github.toservetheking.Kareer.png
|
||||||
32-apps-kareer.png
|
32-apps-io.github.toservetheking.Kareer.png
|
||||||
22-apps-kareer.png
|
22-apps-io.github.toservetheking.Kareer.png
|
||||||
16-apps-kareer.png
|
16-apps-io.github.toservetheking.Kareer.png
|
||||||
DESTINATION ${KDE_INSTALL_ICONDIR}
|
DESTINATION ${KDE_INSTALL_ICONDIR}
|
||||||
THEME hicolor
|
THEME hicolor
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?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">
|
||||||
|
<!--
|
||||||
|
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.
|
||||||
|
-->
|
||||||
|
<!-- Keep this comment inside <svg>: loaders sniff for "<svg" in the first
|
||||||
|
256 bytes, so a long header comment makes the file unrecognisable. -->
|
||||||
|
<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.1 KiB |
@@ -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,37 @@
|
|||||||
<content_rating type="oars-1.1"/>
|
<content_rating type="oars-1.1"/>
|
||||||
|
|
||||||
<releases>
|
<releases>
|
||||||
|
<release version="0.1.6" date="2026-09-11">
|
||||||
|
<description>
|
||||||
|
<p>Ships everything listed for 0.1.5, which was never published because its build failed.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Fix the new app icon not being recognised by software centers, which stopped the Flatpak from building</li>
|
||||||
|
</ul>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<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">
|
<release version="0.1.3" date="2026-08-31">
|
||||||
<description>
|
<description>
|
||||||
<ul>
|
<ul>
|
||||||
|
|||||||
@@ -39,5 +39,5 @@ modules:
|
|||||||
# For Flathub / release builds, pin to a tag AND commit instead:
|
# For Flathub / release builds, pin to a tag AND commit instead:
|
||||||
# - type: git
|
# - type: git
|
||||||
# url: https://github.com/toservetheking/Kareer.git
|
# url: https://github.com/toservetheking/Kareer.git
|
||||||
# tag: v0.1.1
|
# tag: v0.1.6
|
||||||
# commit: <fill in the exact commit SHA the tag points at>
|
# commit: <fill in the exact commit SHA the tag points at>
|
||||||
|
|||||||
@@ -31,10 +31,22 @@ qt_add_qml_module(kareer
|
|||||||
sankeymodel.h
|
sankeymodel.h
|
||||||
jobeditmodel.cpp
|
jobeditmodel.cpp
|
||||||
jobeditmodel.h
|
jobeditmodel.h
|
||||||
|
stagehistorymodel.cpp
|
||||||
|
stagehistorymodel.h
|
||||||
appcolorscheme.cpp
|
appcolorscheme.cpp
|
||||||
appcolorscheme.h
|
appcolorscheme.h
|
||||||
databaselocation.cpp
|
databaselocation.cpp
|
||||||
databaselocation.h
|
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})
|
target_include_directories(kareer PRIVATE ${CMAKE_BINARY_DIR})
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -540,6 +540,74 @@ int runStats(const QString &program, const QStringList &args)
|
|||||||
return 0;
|
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)
|
int runStages(const QString &program, const QStringList &args)
|
||||||
{
|
{
|
||||||
QCommandLineParser parser;
|
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
|
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" 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" 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"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;
|
<< u" --db <path> Use this database file (created if missing) instead of the configured one\n"_s;
|
||||||
return 0;
|
return 0;
|
||||||
@@ -590,6 +659,7 @@ bool Cli::isSubcommand(const QString &arg)
|
|||||||
u"delete"_s,
|
u"delete"_s,
|
||||||
u"stats"_s,
|
u"stats"_s,
|
||||||
u"stages"_s,
|
u"stages"_s,
|
||||||
|
u"history"_s,
|
||||||
u"help"_s,
|
u"help"_s,
|
||||||
};
|
};
|
||||||
return subcommands.contains(arg);
|
return subcommands.contains(arg);
|
||||||
@@ -626,5 +696,8 @@ int Cli::run(QCoreApplication &app)
|
|||||||
if (subcommand == u"stages"_s) {
|
if (subcommand == u"stages"_s) {
|
||||||
return runStages(program, rest);
|
return runStages(program, rest);
|
||||||
}
|
}
|
||||||
|
if (subcommand == u"history"_s) {
|
||||||
|
return runHistory(program, rest);
|
||||||
|
}
|
||||||
return runHelp();
|
return runHelp();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
class QCoreApplication;
|
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
|
* Lets other tools (a resume generator, a shell script) log and query
|
||||||
* applications without ever starting the Kirigami GUI.
|
* applications without ever starting the Kirigami GUI.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ struct Job {
|
|||||||
QDateTime updatedAt;
|
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
|
/// One recorded move from one stage to another (or from "Start" for the
|
||||||
/// initial application), used to build the Sankey diagram.
|
/// initial application), used to build the Sankey diagram.
|
||||||
struct StageTransition {
|
struct StageTransition {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ bool isSalaryField(const QString &id)
|
|||||||
|
|
||||||
JobEditModel::JobEditModel(QObject *parent)
|
JobEditModel::JobEditModel(QObject *parent)
|
||||||
: QAbstractListModel(parent)
|
: QAbstractListModel(parent)
|
||||||
|
, m_history(new StageHistoryModel(this))
|
||||||
, m_fields(JobFieldCatalog::fields())
|
, m_fields(JobFieldCatalog::fields())
|
||||||
{
|
{
|
||||||
resetValues();
|
resetValues();
|
||||||
@@ -123,12 +124,30 @@ void JobEditModel::setEditingJobId(int id)
|
|||||||
void JobEditModel::resetValues()
|
void JobEditModel::resetValues()
|
||||||
{
|
{
|
||||||
m_values.clear();
|
m_values.clear();
|
||||||
m_loadedStage.clear();
|
|
||||||
|
|
||||||
if (m_editingJobId < 0 || !m_jobsModel) {
|
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"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"remoteType"_s] = u"Unspecified"_s;
|
||||||
m_values[u"salaryMin"_s] = 0;
|
m_values[u"salaryMin"_s] = 0;
|
||||||
m_values[u"salaryMax"_s] = 0;
|
m_values[u"salaryMax"_s] = 0;
|
||||||
@@ -145,7 +164,18 @@ void JobEditModel::resetValues()
|
|||||||
}
|
}
|
||||||
m_values[field.id] = value;
|
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) {
|
if (rowCount() > 0) {
|
||||||
@@ -167,6 +197,11 @@ QString JobEditModel::lastError() const
|
|||||||
return m_lastError;
|
return m_lastError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
StageHistoryModel *JobEditModel::history() const
|
||||||
|
{
|
||||||
|
return m_history;
|
||||||
|
}
|
||||||
|
|
||||||
void JobEditModel::setValue(int row, const QVariant &value)
|
void JobEditModel::setValue(int row, const QVariant &value)
|
||||||
{
|
{
|
||||||
if (row < 0 || row >= m_fields.size()) {
|
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_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());
|
setLastError(m_jobsModel->lastError());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateJob() deliberately never writes the stage; stage changes only
|
||||||
|
// ever come from the history.
|
||||||
if (!m_jobsModel->updateJob(m_editingJobId, fields)) {
|
if (!m_jobsModel->updateJob(m_editingJobId, fields)) {
|
||||||
setLastError(m_jobsModel->lastError());
|
setLastError(m_jobsModel->lastError());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (m_history->isEdited()) {
|
||||||
// updateJob() deliberately never writes the stage (so every stage change
|
if (!m_jobsModel->replaceStageHistory(m_editingJobId, steps)) {
|
||||||
// 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)) {
|
|
||||||
setLastError(m_jobsModel->lastError());
|
setLastError(m_jobsModel->lastError());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
m_loadedStage = stage;
|
m_history->load(m_jobsModel->stageHistory(m_editingJobId));
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
#include "jobfieldcatalog.h"
|
#include "jobfieldcatalog.h"
|
||||||
#include "jobsmodel.h"
|
#include "jobsmodel.h"
|
||||||
|
#include "stagehistorymodel.h"
|
||||||
|
|
||||||
#include <QAbstractListModel>
|
#include <QAbstractListModel>
|
||||||
#include <QHash>
|
#include <QHash>
|
||||||
@@ -29,6 +30,9 @@ class JobEditModel : public QAbstractListModel
|
|||||||
Q_PROPERTY(int editingJobId READ editingJobId WRITE setEditingJobId NOTIFY editingJobIdChanged)
|
Q_PROPERTY(int editingJobId READ editingJobId WRITE setEditingJobId NOTIFY editingJobIdChanged)
|
||||||
Q_PROPERTY(QVariantList categories READ categories CONSTANT)
|
Q_PROPERTY(QVariantList categories READ categories CONSTANT)
|
||||||
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
|
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:
|
public:
|
||||||
enum Roles {
|
enum Roles {
|
||||||
@@ -58,6 +62,7 @@ public:
|
|||||||
|
|
||||||
QVariantList categories() const;
|
QVariantList categories() const;
|
||||||
QString lastError() const;
|
QString lastError() const;
|
||||||
|
StageHistoryModel *history() const;
|
||||||
|
|
||||||
/// Updates the value for the field at this row (called from the QML delegate).
|
/// Updates the value for the field at this row (called from the QML delegate).
|
||||||
Q_INVOKABLE void setValue(int row, const QVariant &value);
|
Q_INVOKABLE void setValue(int row, const QVariant &value);
|
||||||
@@ -80,6 +85,6 @@ private:
|
|||||||
int m_editingJobId = -1;
|
int m_editingJobId = -1;
|
||||||
QHash<QString, QVariant> m_values;
|
QHash<QString, QVariant> m_values;
|
||||||
QString m_lastError;
|
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;
|
QList<JobFieldCatalog::Field> m_fields;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
#include "jobfieldcatalog.h"
|
#include "jobfieldcatalog.h"
|
||||||
#include "jobstage.h"
|
|
||||||
|
|
||||||
using namespace Qt::Literals::StringLiterals;
|
using namespace Qt::Literals::StringLiterals;
|
||||||
|
|
||||||
@@ -37,8 +36,9 @@ QList<Field> fields()
|
|||||||
0,
|
0,
|
||||||
0},
|
0},
|
||||||
|
|
||||||
{u"stage"_s, u"pipeline"_s, QStringLiteral("Stage:"), ComboRow, JobStage::canonicalStages(), {}, 0, 0},
|
// The "pipeline" category has no plain fields: ApplicationEditPage
|
||||||
{u"dateApplied"_s, u"pipeline"_s, QStringLiteral("Date Applied:"), DateRow, {}, {}, 0, 0},
|
// 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"salaryMin"_s, u"salary"_s, QStringLiteral("Range Minimum:"), SpinBoxRow, {}, {}, 0, 5000000},
|
||||||
{u"salaryMax"_s, u"salary"_s, QStringLiteral("Range Maximum:"), SpinBoxRow, {}, {}, 0, 5000000},
|
{u"salaryMax"_s, u"salary"_s, QStringLiteral("Range Maximum:"), SpinBoxRow, {}, {}, 0, 5000000},
|
||||||
|
|||||||
@@ -479,6 +479,148 @@ bool JobsDatabase::deleteJob(int id)
|
|||||||
return true;
|
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> JobsDatabase::stageTransitions() const
|
||||||
{
|
{
|
||||||
QList<StageTransition> transitions;
|
QList<StageTransition> transitions;
|
||||||
|
|||||||
@@ -87,6 +87,23 @@ public:
|
|||||||
|
|
||||||
bool deleteJob(int id);
|
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;
|
QList<StageTransition> stageTransitions() const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|||||||
@@ -167,14 +167,14 @@ QVariantMap JobsModel::jobData(int id) const
|
|||||||
return mapFromJob(*job);
|
return mapFromJob(*job);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool JobsModel::addJob(const QVariantMap &fields)
|
int JobsModel::addJob(const QVariantMap &fields)
|
||||||
{
|
{
|
||||||
Job job = jobFromMap(fields);
|
Job job = jobFromMap(fields);
|
||||||
const bool ok = m_db.addJob(job);
|
if (!m_db.addJob(job)) {
|
||||||
if (ok) {
|
return -1;
|
||||||
refresh();
|
|
||||||
}
|
}
|
||||||
return ok;
|
refresh();
|
||||||
|
return job.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool JobsModel::updateJob(int id, const QVariantMap &fields)
|
bool JobsModel::updateJob(int id, const QVariantMap &fields)
|
||||||
@@ -206,6 +206,20 @@ bool JobsModel::removeJob(int id)
|
|||||||
return ok;
|
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
|
QString JobsModel::lastError() const
|
||||||
{
|
{
|
||||||
return m_db.lastError();
|
return m_db.lastError();
|
||||||
|
|||||||
@@ -60,11 +60,15 @@ public:
|
|||||||
/// Full record for one job, for prefilling the edit dialog.
|
/// Full record for one job, for prefilling the edit dialog.
|
||||||
Q_INVOKABLE QVariantMap jobData(int id) const;
|
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 updateJob(int id, const QVariantMap &fields);
|
||||||
Q_INVOKABLE bool setStage(int id, const QString &stage);
|
Q_INVOKABLE bool setStage(int id, const QString &stage);
|
||||||
Q_INVOKABLE bool removeJob(int id);
|
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;
|
Q_INVOKABLE QString lastError() const;
|
||||||
|
|
||||||
public Q_SLOTS:
|
public Q_SLOTS:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
#include "kareer-version.h"
|
#include "kareer-version.h"
|
||||||
|
|
||||||
|
#include "autoghost.h"
|
||||||
#include "clicommands.h"
|
#include "clicommands.h"
|
||||||
#include "databaselocation.h"
|
#include "databaselocation.h"
|
||||||
#include "jobsdatabase.h"
|
#include "jobsdatabase.h"
|
||||||
@@ -110,6 +111,10 @@ int main(int argc, char *argv[])
|
|||||||
if (argc >= 2 && Cli::isSubcommand(QString::fromLocal8Bit(argv[1]))) {
|
if (argc >= 2 && Cli::isSubcommand(QString::fromLocal8Bit(argv[1]))) {
|
||||||
QCoreApplication app(argc, argv);
|
QCoreApplication app(argc, argv);
|
||||||
DatabaseLocation::loadConfiguredPath();
|
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);
|
return Cli::run(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +139,7 @@ int main(int argc, char *argv[])
|
|||||||
aboutData.setDesktopFileName(u"io.github.toservetheking.Kareer"_s);
|
aboutData.setDesktopFileName(u"io.github.toservetheking.Kareer"_s);
|
||||||
KAboutData::setApplicationData(aboutData);
|
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();
|
KCrash::initialize();
|
||||||
|
|
||||||
@@ -149,6 +154,9 @@ int main(int argc, char *argv[])
|
|||||||
// First run (or the configured file has gone missing): open nothing until
|
// First run (or the configured file has gone missing): open nothing until
|
||||||
// the user picks a location in DatabaseSetupDialog. The CLI never waits.
|
// the user picks a location in DatabaseSetupDialog. The CLI never waits.
|
||||||
JobsDatabase::setSelectionPending(DatabaseLocation::needsSetup());
|
JobsDatabase::setSelectionPending(DatabaseLocation::needsSetup());
|
||||||
|
if (!JobsDatabase::selectionPending()) {
|
||||||
|
AutoGhost::runNow(); // before the models load; Main.qml reports the count
|
||||||
|
}
|
||||||
|
|
||||||
QQmlApplicationEngine engine;
|
QQmlApplicationEngine engine;
|
||||||
KLocalization::setupLocalizedContext(&engine);
|
KLocalization::setupLocalizedContext(&engine);
|
||||||
|
|||||||
@@ -103,7 +103,80 @@ Kirigami.ScrollablePage {
|
|||||||
filterString: section.modelData.id
|
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 {
|
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.fillWidth: true
|
||||||
Layout.leftMargin: Kirigami.Units.largeSpacing
|
Layout.leftMargin: Kirigami.Units.largeSpacing
|
||||||
Layout.rightMargin: Kirigami.Units.largeSpacing
|
Layout.rightMargin: Kirigami.Units.largeSpacing
|
||||||
|
|||||||
@@ -31,11 +31,28 @@ Kirigami.ApplicationWindow {
|
|||||||
Connections {
|
Connections {
|
||||||
target: DatabaseLocation
|
target: DatabaseLocation
|
||||||
function onChanged(): void {
|
function onChanged(): void {
|
||||||
|
AutoGhost.run();
|
||||||
jobsModel.refresh();
|
jobsModel.refresh();
|
||||||
root.showDashboard();
|
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 {
|
DatabaseSetupDialog {
|
||||||
id: setupDialog
|
id: setupDialog
|
||||||
}
|
}
|
||||||
@@ -43,6 +60,8 @@ Kirigami.ApplicationWindow {
|
|||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
if (DatabaseLocation.setupPending) {
|
if (DatabaseLocation.setupPending) {
|
||||||
setupDialog.open();
|
setupDialog.open();
|
||||||
|
} else if (AutoGhost.lastRunCount > 0) {
|
||||||
|
root.announceGhosted(AutoGhost.lastRunCount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
Component.onCompleted: DatabaseLocation.clearMessages()
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||