Editable stage history, auto-ghosting, and an Add-form fix

Stage history editor:
- The edit form's Pipeline section is now the application's history:
  one row per step (stage + date), Add Step and Remove Step, kept in
  date order. The last step is the current stage and the first step's
  date the date applied, replacing the separate Stage and Date Applied
  fields. An application logged after the fact (applied, interviewed,
  rejected) keeps its whole path.
- JobsDatabase::replaceStageHistory() rewrites a job's history in one
  transaction (sorted, repeats collapsed, stage and date applied kept in
  step); StageHistoryModel backs the section; JobsModel::addJob() now
  returns the new id so a new application's history can be saved.
- `kareer history <id> [Stage=YYYY-MM-DD ...]` shows or replaces it.

Auto-ghosting:
- Applications still at Applied with no activity (the later of the
  date applied and the last stage change) for more than 30 days move to
  Ghosted, recorded like any stage change. Runs at startup for GUI and
  CLI, after switching databases, and shortly after the setting changes.
- Preferences gains an Applications section: on/off and the number of
  days (kareerrc [AutoGhost]). The GUI shows a passive notification; the
  CLI notes it on stderr so --json output stays clean.

Fixes:
- The Add Application form pre-filled empty text fields with the word
  "undefined" (typing "a" gave "undefineda"): fields with no value
  reached QML as undefined. Every field now gets a typed default.
- Embed the app icon as the window icon fallback, so the window and
  About page show it when running uninstalled.

Tests: history replacement and the after-the-fact Allstate case, the
ghosting rules (fresh/stale/logged-late/reopened/threshold), and empty
new-form fields.

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