Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e79489a421 | ||
|
|
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()`).
|
||||
|
||||
**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.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
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_KF_VERSION 6.5.0)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -46,6 +46,7 @@ add_executable(jobeditmodeltest
|
||||
../src/jobsdatabase.cpp
|
||||
../src/jobsmodel.cpp
|
||||
../src/jobeditmodel.cpp
|
||||
../src/stagehistorymodel.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"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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
# Maintainer: Austin Bennett <austin at thebennett dot net>
|
||||
pkgname=kareer
|
||||
pkgver=0.1.3
|
||||
pkgver=0.1.6
|
||||
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=('c119d0e18448450cb0d12a16b895b0262ecee2c798bae9bdfa1feb03a6f3809e')
|
||||
|
||||
build() {
|
||||
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
|
||||
|
||||
# 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
|
||||
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,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"/>
|
||||
|
||||
<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">
|
||||
<description>
|
||||
<ul>
|
||||
|
||||
@@ -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.6
|
||||
# commit: <fill in the exact commit SHA the tag points at>
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
};
|
||||