diff --git a/CLAUDE.md b/CLAUDE.md index b0acbbf..0afbe5b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 [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. diff --git a/README.md b/README.md index 60d6499..49ec6bf 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt index 0f64e97..19208d3 100644 --- a/autotests/CMakeLists.txt +++ b/autotests/CMakeLists.txt @@ -46,6 +46,7 @@ add_executable(jobeditmodeltest ../src/jobsdatabase.cpp ../src/jobsmodel.cpp ../src/jobeditmodel.cpp + ../src/stagehistorymodel.cpp ../src/jobfieldcatalog.cpp ) diff --git a/autotests/jobeditmodeltest.cpp b/autotests/jobeditmodeltest.cpp index 5e39ef8..c8c025e 100644 --- a/autotests/jobeditmodeltest.cpp +++ b/autotests/jobeditmodeltest.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 all = db.allJobs(); + QCOMPARE(all.size(), 1); + QCOMPARE(all.first().stage, u"Rejected"_s); + QCOMPARE(all.first().dateApplied, QDate(2026, 8, 1)); + const QList 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; diff --git a/autotests/jobsdatabasetest.cpp b/autotests/jobsdatabasetest.cpp index 6b5f402..621f748 100644 --- a/autotests/jobsdatabasetest.cpp +++ b/autotests/jobsdatabasetest.cpp @@ -7,6 +7,8 @@ #include "jobsdatabase.h" #include "job.h" +#include +#include #include #include @@ -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 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 &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 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) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 182c853..1f9bc6b 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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}) diff --git a/src/autoghost.cpp b/src/autoghost.cpp new file mode 100644 index 0000000..0ae6c9a --- /dev/null +++ b/src/autoghost.cpp @@ -0,0 +1,92 @@ +/* + SPDX-FileCopyrightText: 2026 ToServeTheKing + + SPDX-License-Identifier: GPL-3.0-or-later +*/ + +#include "autoghost.h" + +#include "jobsdatabase.h" + +#include +#include + +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; +} diff --git a/src/autoghost.h b/src/autoghost.h new file mode 100644 index 0000000..988553a --- /dev/null +++ b/src/autoghost.h @@ -0,0 +1,61 @@ +/* + SPDX-FileCopyrightText: 2026 ToServeTheKing + + SPDX-License-Identifier: GPL-3.0-or-later +*/ + +#pragma once + +#include +#include +#include + +/** + * 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; +}; diff --git a/src/clicommands.cpp b/src/clicommands.cpp index fc5ab44..d9e1f46 100644 --- a/src/clicommands.cpp +++ b/src/clicommands.cpp @@ -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 [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 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 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 [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 --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 --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 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(); } diff --git a/src/clicommands.h b/src/clicommands.h index 688d53f..459da45 100644 --- a/src/clicommands.h +++ b/src/clicommands.h @@ -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. */ diff --git a/src/job.h b/src/job.h index fd0e458..079ce50 100644 --- a/src/job.h +++ b/src/job.h @@ -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 { diff --git a/src/jobeditmodel.cpp b/src/jobeditmodel.cpp index 66fdf55..fd9c4f4 100644 --- a/src/jobeditmodel.cpp +++ b/src/jobeditmodel.cpp @@ -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 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 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; } diff --git a/src/jobeditmodel.h b/src/jobeditmodel.h index 387735b..029fe7d 100644 --- a/src/jobeditmodel.h +++ b/src/jobeditmodel.h @@ -8,6 +8,7 @@ #include "jobfieldcatalog.h" #include "jobsmodel.h" +#include "stagehistorymodel.h" #include #include @@ -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 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 m_fields; }; diff --git a/src/jobfieldcatalog.cpp b/src/jobfieldcatalog.cpp index 9e836c6..40e9dbf 100644 --- a/src/jobfieldcatalog.cpp +++ b/src/jobfieldcatalog.cpp @@ -5,7 +5,6 @@ */ #include "jobfieldcatalog.h" -#include "jobstage.h" using namespace Qt::Literals::StringLiterals; @@ -37,8 +36,9 @@ QList 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}, diff --git a/src/jobsdatabase.cpp b/src/jobsdatabase.cpp index e2e8d7e..d5c566b 100644 --- a/src/jobsdatabase.cpp +++ b/src/jobsdatabase.cpp @@ -479,6 +479,148 @@ bool JobsDatabase::deleteJob(int id) return true; } +QList JobsDatabase::stageHistory(int jobId) const +{ + QList 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 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 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 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 JobsDatabase::stageTransitions() const { QList transitions; diff --git a/src/jobsdatabase.h b/src/jobsdatabase.h index 22fdc6e..78a613f 100644 --- a/src/jobsdatabase.h +++ b/src/jobsdatabase.h @@ -87,6 +87,23 @@ public: bool deleteJob(int id); + /// The steps one application went through, oldest first. + QList 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 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 stageTransitions() const; private: diff --git a/src/jobsmodel.cpp b/src/jobsmodel.cpp index 7a3b167..f44f195 100644 --- a/src/jobsmodel.cpp +++ b/src/jobsmodel.cpp @@ -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 JobsModel::stageHistory(int id) const +{ + return m_db.stageHistory(id); +} + +bool JobsModel::replaceStageHistory(int id, const QList &steps) +{ + const bool ok = m_db.replaceStageHistory(id, steps); + if (ok) { + refresh(); + } + return ok; +} + QString JobsModel::lastError() const { return m_db.lastError(); diff --git a/src/jobsmodel.h b/src/jobsmodel.h index 709bc9a..ab5b875 100644 --- a/src/jobsmodel.h +++ b/src/jobsmodel.h @@ -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 stageHistory(int id) const; + bool replaceStageHistory(int id, const QList &steps); + Q_INVOKABLE QString lastError() const; public Q_SLOTS: diff --git a/src/main.cpp b/src/main.cpp index 831cc1d..120978c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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); diff --git a/src/qml/ApplicationEditPage.qml b/src/qml/ApplicationEditPage.qml index 9952aef..a0ca1bb 100644 --- a/src/qml/ApplicationEditPage.qml +++ b/src/qml/ApplicationEditPage.qml @@ -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 diff --git a/src/qml/Main.qml b/src/qml/Main.qml index 1dc6a86..0482e3c 100644 --- a/src/qml/Main.qml +++ b/src/qml/Main.qml @@ -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); } } diff --git a/src/qml/SettingsPage.qml b/src/qml/SettingsPage.qml index 0258e7d..c26dc00 100644 --- a/src/qml/SettingsPage.qml +++ b/src/qml/SettingsPage.qml @@ -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() diff --git a/src/stagehistorymodel.cpp b/src/stagehistorymodel.cpp new file mode 100644 index 0000000..7e4aa90 --- /dev/null +++ b/src/stagehistorymodel.cpp @@ -0,0 +1,156 @@ +/* + SPDX-FileCopyrightText: 2026 ToServeTheKing + + SPDX-License-Identifier: GPL-3.0-or-later +*/ + +#include "stagehistorymodel.h" + +#include "jobstage.h" + +#include + +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 StageHistoryModel::roleNames() const +{ + return {{StageRole, "stage"}, {DateRole, "date"}}; +} + +QStringList StageHistoryModel::stages() const +{ + return JobStage::canonicalStages(); +} + +void StageHistoryModel::load(const QList &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 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(); +} diff --git a/src/stagehistorymodel.h b/src/stagehistorymodel.h new file mode 100644 index 0000000..61b5716 --- /dev/null +++ b/src/stagehistorymodel.h @@ -0,0 +1,70 @@ +/* + SPDX-FileCopyrightText: 2026 ToServeTheKing + + SPDX-License-Identifier: GPL-3.0-or-later +*/ + +#pragma once + +#include "job.h" + +#include +#include + +/** + * 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 roleNames() const override; + + QStringList stages() const; + + /// Replaces every step without marking the history as edited. + void load(const QList &steps); + QList 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 m_steps; + bool m_edited = false; +};