Initial commit: Kareer, a Kirigami job application tracker

C++/Qt6/KDE Frameworks 6 app with a SQLite-backed data layer, a
Sankey-diagram dashboard, and a full CLI (add/list/show/update/stage/
delete/stats/stages) for scripting from other tools.
This commit is contained in:
2026-07-03 10:35:16 -05:00
commit 337eb1a6bd
33 changed files with 4071 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
# SPDX-License-Identifier: GPL-3.0-or-later
add_executable(kareer
main.cpp
jobstage.cpp
jobsdatabase.cpp
clicommands.cpp
)
qt_add_qml_module(kareer
URI io.github.toservetheking.Kareer
VERSION 1.0
RESOURCE_PREFIX /qt/qml
QML_FILES
qml/Main.qml
qml/ApplicationsPage.qml
qml/ApplicationEditDialog.qml
qml/DashboardPage.qml
qml/SankeyDiagram.qml
SOURCES
jobsmodel.cpp
jobsmodel.h
statsmodel.cpp
statsmodel.h
sankeymodel.cpp
sankeymodel.h
)
target_include_directories(kareer PRIVATE ${CMAKE_BINARY_DIR})
target_link_libraries(kareer PRIVATE
Qt6::Core
Qt6::Gui
Qt6::Widgets
Qt6::Qml
Qt6::Quick
Qt6::QuickControls2
Qt6::Sql
KF6::I18n
KF6::I18nQml
KF6::CoreAddons
KF6::IconThemes
KF6::Crash
)
install(TARGETS kareer ${KDE_INSTALL_TARGETS_DEFAULT_ARGS})
+608
View File
@@ -0,0 +1,608 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "clicommands.h"
#include "job.h"
#include "jobsdatabase.h"
#include "jobstage.h"
#include "statsmodel.h"
#include <QCommandLineOption>
#include <QCommandLineParser>
#include <QCoreApplication>
#include <QDate>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSet>
#include <QTextStream>
using namespace Qt::Literals::StringLiterals;
namespace
{
QJsonValue optionalInt(int value)
{
return value < 0 ? QJsonValue() : QJsonValue(value);
}
QJsonObject jobToJson(const Job &job)
{
QJsonObject o;
o["id"_L1] = job.id;
o["company"_L1] = job.company;
o["title"_L1] = job.title;
o["location"_L1] = job.location;
o["remoteType"_L1] = job.remoteType;
o["source"_L1] = job.source;
o["url"_L1] = job.url;
o["dateApplied"_L1] = job.dateApplied.isValid() ? QJsonValue(job.dateApplied.toString(Qt::ISODate)) : QJsonValue();
o["salaryMin"_L1] = optionalInt(job.salaryMin);
o["salaryMax"_L1] = optionalInt(job.salaryMax);
o["salaryExpectation"_L1] = optionalInt(job.salaryExpectation);
o["currency"_L1] = job.currency;
o["notes"_L1] = job.notes;
o["contact"_L1] = job.contact;
o["stage"_L1] = job.stage;
o["createdAt"_L1] = job.createdAt.toString(Qt::ISODate);
o["updatedAt"_L1] = job.updatedAt.toString(Qt::ISODate);
return o;
}
void printJson(const QJsonValue &value)
{
const QJsonDocument doc = value.isArray() ? QJsonDocument(value.toArray()) : QJsonDocument(value.toObject());
QTextStream(stdout) << QString::fromUtf8(doc.toJson(QJsonDocument::Compact)) << Qt::endl;
}
void printJobHuman(const Job &job, QTextStream &out)
{
out << u"#"_s << job.id << u" "_s << job.company << u""_s << job.title << u" ["_s << job.stage << u"]"_s << Qt::endl;
if (!job.location.isEmpty() || !job.remoteType.isEmpty()) {
out << u" Location: "_s << job.location;
if (!job.remoteType.isEmpty()) {
out << u" ("_s << job.remoteType << u")"_s;
}
out << Qt::endl;
}
if (job.dateApplied.isValid()) {
out << u" Applied: "_s << job.dateApplied.toString(Qt::ISODate) << Qt::endl;
}
if (job.salaryMin >= 0 || job.salaryMax >= 0) {
out << u" Salary: "_s;
if (job.salaryMin >= 0) {
out << job.salaryMin;
}
if (job.salaryMin >= 0 && job.salaryMax >= 0) {
out << u""_s;
}
if (job.salaryMax >= 0) {
out << job.salaryMax;
}
out << u" "_s << job.currency << Qt::endl;
}
if (job.salaryExpectation >= 0) {
out << u" Expectation: "_s << job.salaryExpectation << u" "_s << job.currency << Qt::endl;
}
if (!job.source.isEmpty()) {
out << u" Source: "_s << job.source << Qt::endl;
}
if (!job.url.isEmpty()) {
out << u" URL: "_s << job.url << Qt::endl;
}
if (!job.contact.isEmpty()) {
out << u" Contact: "_s << job.contact << Qt::endl;
}
if (!job.notes.isEmpty()) {
out << u" Notes: "_s << job.notes << Qt::endl;
}
}
bool normalizeRemoteType(const QString &input, QString &out, QString &error)
{
if (input.isEmpty()) {
out.clear();
return true;
}
static const QStringList canonical{u"Onsite"_s, u"Hybrid"_s, u"Remote"_s};
for (const QString &candidate : canonical) {
if (candidate.compare(input, Qt::CaseInsensitive) == 0) {
out = candidate;
return true;
}
}
error = u"Invalid --remote value '%1' (expected onsite, hybrid, or remote)"_s.arg(input);
return false;
}
void addCommonJobOptions(QCommandLineParser &parser)
{
parser.addOption({u"company"_s, u"Company name"_s, u"company"_s});
parser.addOption({u"title"_s, u"Job title"_s, u"title"_s});
parser.addOption({u"location"_s, u"Location"_s, u"location"_s});
parser.addOption({u"remote"_s, u"Remote type: onsite, hybrid, or remote"_s, u"type"_s});
parser.addOption({u"source"_s, u"Where this lead came from (referral, LinkedIn, ...)"_s, u"source"_s});
parser.addOption({u"url"_s, u"Job posting URL"_s, u"url"_s});
parser.addOption({u"date-applied"_s, u"Date applied, YYYY-MM-DD (default: today)"_s, u"date"_s});
parser.addOption({u"salary-min"_s, u"Posted salary range minimum"_s, u"amount"_s});
parser.addOption({u"salary-max"_s, u"Posted salary range maximum"_s, u"amount"_s});
parser.addOption({u"salary-expectation"_s, u"Your stated salary expectation"_s, u"amount"_s});
parser.addOption({u"currency"_s, u"Currency code (default: USD)"_s, u"code"_s});
parser.addOption({u"notes"_s, u"Free-text notes / expectations"_s, u"text"_s});
parser.addOption({u"contact"_s, u"Recruiter or contact name"_s, u"name"_s});
parser.addOption({u"stage"_s, u"Pipeline stage (default: Applied)"_s, u"stage"_s});
parser.addOption({u"json"_s, u"Print machine-readable JSON"_s});
}
bool parseSalaryOption(QCommandLineParser &parser, const QString &option, int &target, QTextStream &err)
{
if (!parser.isSet(option)) {
return true;
}
bool ok = false;
const int value = parser.value(option).toInt(&ok);
if (!ok || value < 0) {
err << u"Error: --%1 must be a non-negative integer"_s.arg(option) << Qt::endl;
return false;
}
target = value;
return true;
}
int runAdd(const QString &program, const QStringList &args)
{
QCommandLineParser parser;
parser.setApplicationDescription(u"Add a new job application"_s);
parser.addHelpOption();
addCommonJobOptions(parser);
parser.process(QStringList{program} + args);
QTextStream err(stderr);
if (!parser.isSet(u"company"_s) || !parser.isSet(u"title"_s)) {
err << u"Error: --company and --title are required"_s << Qt::endl;
return 1;
}
Job job;
job.company = parser.value(u"company"_s);
job.title = parser.value(u"title"_s);
job.location = parser.value(u"location"_s);
job.source = parser.value(u"source"_s);
job.url = parser.value(u"url"_s);
job.contact = parser.value(u"contact"_s);
job.notes = parser.value(u"notes"_s);
job.currency = parser.isSet(u"currency"_s) ? parser.value(u"currency"_s) : u"USD"_s;
job.stage = parser.isSet(u"stage"_s) ? parser.value(u"stage"_s) : u"Applied"_s;
QString remoteError;
if (!normalizeRemoteType(parser.value(u"remote"_s), job.remoteType, remoteError)) {
err << remoteError << Qt::endl;
return 1;
}
if (parser.isSet(u"date-applied"_s)) {
job.dateApplied = QDate::fromString(parser.value(u"date-applied"_s), Qt::ISODate);
if (!job.dateApplied.isValid()) {
err << u"Error: --date-applied must be YYYY-MM-DD"_s << Qt::endl;
return 1;
}
} else {
job.dateApplied = QDate::currentDate();
}
if (!parseSalaryOption(parser, u"salary-min"_s, job.salaryMin, err) || !parseSalaryOption(parser, u"salary-max"_s, job.salaryMax, err)
|| !parseSalaryOption(parser, u"salary-expectation"_s, job.salaryExpectation, err)) {
return 1;
}
if (!JobStage::isValid(job.stage)) {
err << u"Error: unknown stage '%1'. Valid stages: %2"_s.arg(job.stage, JobStage::canonicalStages().join(u", "_s)) << Qt::endl;
return 1;
}
JobsDatabase db;
if (!db.addJob(job)) {
err << u"Error: %1"_s.arg(db.lastError()) << Qt::endl;
return 1;
}
if (parser.isSet(u"json"_s)) {
printJson(jobToJson(job));
} else {
QTextStream(stdout) << u"Added application #%1: %2 — %3 (%4)"_s.arg(job.id).arg(job.company, job.title, job.stage) << Qt::endl;
}
return 0;
}
int runList(const QString &program, const QStringList &args)
{
QCommandLineParser parser;
parser.setApplicationDescription(u"List job applications"_s);
parser.addHelpOption();
parser.addOption({u"stage"_s, u"Filter by stage"_s, u"stage"_s});
parser.addOption({u"company"_s, u"Filter by company (substring match)"_s, u"text"_s});
parser.addOption({u"json"_s, u"Print machine-readable JSON"_s});
parser.process(QStringList{program} + args);
JobsDatabase db;
QList<Job> jobs = db.allJobs();
if (parser.isSet(u"stage"_s)) {
const QString stage = parser.value(u"stage"_s);
jobs.removeIf([&](const Job &j) {
return j.stage.compare(stage, Qt::CaseInsensitive) != 0;
});
}
if (parser.isSet(u"company"_s)) {
const QString needle = parser.value(u"company"_s);
jobs.removeIf([&](const Job &j) {
return !j.company.contains(needle, Qt::CaseInsensitive);
});
}
if (parser.isSet(u"json"_s)) {
QJsonArray arr;
for (const Job &j : std::as_const(jobs)) {
arr.append(jobToJson(j));
}
printJson(arr);
return 0;
}
QTextStream out(stdout);
if (jobs.isEmpty()) {
out << u"No applications found."_s << Qt::endl;
return 0;
}
for (const Job &j : std::as_const(jobs)) {
out << u"#"_s << j.id << u" "_s << j.company << u""_s << j.title << u" ["_s << j.stage << u"] "_s
<< (j.dateApplied.isValid() ? j.dateApplied.toString(Qt::ISODate) : u"?"_s) << Qt::endl;
}
return 0;
}
int runShow(const QString &program, const QStringList &args)
{
QCommandLineParser parser;
parser.setApplicationDescription(u"Show one job application"_s);
parser.addHelpOption();
parser.addOption({u"json"_s, u"Print machine-readable JSON"_s});
parser.addPositionalArgument(u"id"_s, u"Application id"_s);
parser.process(QStringList{program} + args);
QTextStream err(stderr);
const QStringList positional = parser.positionalArguments();
if (positional.isEmpty()) {
err << u"Error: missing application id"_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;
const auto job = db.jobById(id);
if (!job) {
err << u"Error: no application #%1"_s.arg(id) << Qt::endl;
return 1;
}
if (parser.isSet(u"json"_s)) {
printJson(jobToJson(*job));
} else {
QTextStream out(stdout);
printJobHuman(*job, out);
}
return 0;
}
int runUpdate(const QString &program, const QStringList &args)
{
QCommandLineParser parser;
parser.setApplicationDescription(u"Update fields on an existing application"_s);
parser.addHelpOption();
addCommonJobOptions(parser);
parser.addPositionalArgument(u"id"_s, u"Application id"_s);
parser.process(QStringList{program} + args);
QTextStream err(stderr);
const QStringList positional = parser.positionalArguments();
if (positional.isEmpty()) {
err << u"Error: missing application id"_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;
const auto existing = db.jobById(id);
if (!existing) {
err << u"Error: no application #%1"_s.arg(id) << Qt::endl;
return 1;
}
Job job = *existing;
if (parser.isSet(u"company"_s)) {
job.company = parser.value(u"company"_s);
}
if (parser.isSet(u"title"_s)) {
job.title = parser.value(u"title"_s);
}
if (parser.isSet(u"location"_s)) {
job.location = parser.value(u"location"_s);
}
if (parser.isSet(u"source"_s)) {
job.source = parser.value(u"source"_s);
}
if (parser.isSet(u"url"_s)) {
job.url = parser.value(u"url"_s);
}
if (parser.isSet(u"contact"_s)) {
job.contact = parser.value(u"contact"_s);
}
if (parser.isSet(u"notes"_s)) {
job.notes = parser.value(u"notes"_s);
}
if (parser.isSet(u"currency"_s)) {
job.currency = parser.value(u"currency"_s);
}
if (parser.isSet(u"remote"_s)) {
QString remoteError;
if (!normalizeRemoteType(parser.value(u"remote"_s), job.remoteType, remoteError)) {
err << remoteError << Qt::endl;
return 1;
}
}
if (parser.isSet(u"date-applied"_s)) {
const QDate d = QDate::fromString(parser.value(u"date-applied"_s), Qt::ISODate);
if (!d.isValid()) {
err << u"Error: --date-applied must be YYYY-MM-DD"_s << Qt::endl;
return 1;
}
job.dateApplied = d;
}
if (!parseSalaryOption(parser, u"salary-min"_s, job.salaryMin, err) || !parseSalaryOption(parser, u"salary-max"_s, job.salaryMax, err)
|| !parseSalaryOption(parser, u"salary-expectation"_s, job.salaryExpectation, err)) {
return 1;
}
if (!db.updateJob(job)) {
err << u"Error: %1"_s.arg(db.lastError()) << Qt::endl;
return 1;
}
if (parser.isSet(u"stage"_s)) {
const QString stage = parser.value(u"stage"_s);
if (!JobStage::isValid(stage)) {
err << u"Error: unknown stage '%1'. Valid stages: %2"_s.arg(stage, JobStage::canonicalStages().join(u", "_s)) << Qt::endl;
return 1;
}
if (!db.setStage(id, stage)) {
err << u"Error: %1"_s.arg(db.lastError()) << Qt::endl;
return 1;
}
}
const auto updated = db.jobById(id);
if (parser.isSet(u"json"_s)) {
printJson(jobToJson(*updated));
} else {
QTextStream(stdout) << u"Updated application #%1"_s.arg(id) << Qt::endl;
}
return 0;
}
int runStage(const QString &program, const QStringList &args)
{
QCommandLineParser parser;
parser.setApplicationDescription(u"Move an application to a new stage"_s);
parser.addHelpOption();
parser.addOption({u"json"_s, u"Print machine-readable JSON"_s});
parser.addPositionalArgument(u"id"_s, u"Application id"_s);
parser.addPositionalArgument(u"stage"_s, u"New stage: %1"_s.arg(JobStage::canonicalStages().join(u", "_s)));
parser.process(QStringList{program} + args);
QTextStream err(stderr);
const QStringList positional = parser.positionalArguments();
if (positional.size() < 2) {
err << u"Error: usage: kareer stage <id> <stage>"_s << Qt::endl;
return 1;
}
bool ok = false;
const int id = positional.at(0).toInt(&ok);
if (!ok) {
err << u"Error: id must be an integer"_s << Qt::endl;
return 1;
}
const QString stage = positional.at(1);
if (!JobStage::isValid(stage)) {
err << u"Error: unknown stage '%1'. Valid stages: %2"_s.arg(stage, JobStage::canonicalStages().join(u", "_s)) << Qt::endl;
return 1;
}
JobsDatabase db;
if (!db.setStage(id, stage)) {
err << u"Error: %1"_s.arg(db.lastError()) << Qt::endl;
return 1;
}
const auto job = db.jobById(id);
if (parser.isSet(u"json"_s)) {
printJson(jobToJson(*job));
} else {
QTextStream(stdout) << u"Application #%1 moved to %2"_s.arg(id).arg(stage) << Qt::endl;
}
return 0;
}
int runDelete(const QString &program, const QStringList &args)
{
QCommandLineParser parser;
parser.setApplicationDescription(u"Delete an application"_s);
parser.addHelpOption();
parser.addOption({u"yes"_s, u"Confirm deletion"_s});
parser.addPositionalArgument(u"id"_s, u"Application id"_s);
parser.process(QStringList{program} + args);
QTextStream err(stderr);
const QStringList positional = parser.positionalArguments();
if (positional.isEmpty()) {
err << u"Error: missing application id"_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;
}
if (!parser.isSet(u"yes"_s)) {
err << u"Refusing to delete application #%1 without --yes"_s.arg(id) << Qt::endl;
return 1;
}
JobsDatabase db;
if (!db.deleteJob(id)) {
err << u"Error: %1"_s.arg(db.lastError()) << Qt::endl;
return 1;
}
QTextStream(stdout) << u"Deleted application #%1"_s.arg(id) << Qt::endl;
return 0;
}
int runStats(const QString &program, const QStringList &args)
{
QCommandLineParser parser;
parser.setApplicationDescription(u"Summary statistics across all applications"_s);
parser.addHelpOption();
parser.addOption({u"json"_s, u"Print machine-readable JSON"_s});
parser.process(QStringList{program} + args);
StatsModel stats;
const QVariantMap counts = stats.stageCounts();
if (parser.isSet(u"json"_s)) {
QJsonObject o;
o["totalApplications"_L1] = stats.totalApplications();
o["activeApplications"_L1] = stats.activeApplications();
o["offerCount"_L1] = stats.offerCount();
o["acceptedCount"_L1] = stats.acceptedCount();
o["rejectedCount"_L1] = stats.rejectedCount();
o["responseRate"_L1] = stats.responseRate();
o["offerRate"_L1] = stats.offerRate();
QJsonObject stageCounts;
for (auto it = counts.constBegin(); it != counts.constEnd(); ++it) {
stageCounts[it.key()] = it.value().toInt();
}
o["stageCounts"_L1] = stageCounts;
printJson(o);
return 0;
}
QTextStream out(stdout);
out << u"Total applications: %1"_s.arg(stats.totalApplications()) << Qt::endl;
out << u"Active: %1"_s.arg(stats.activeApplications()) << Qt::endl;
out << u"Offers: %1 Accepted: %2 Rejected: %3"_s.arg(stats.offerCount()).arg(stats.acceptedCount()).arg(stats.rejectedCount()) << Qt::endl;
out << u"Response rate: %1% Offer rate: %2%"_s.arg(stats.responseRate(), 0, 'f', 1).arg(stats.offerRate(), 0, 'f', 1) << Qt::endl;
out << u"By stage:"_s << Qt::endl;
for (const QString &stage : JobStage::canonicalStages()) {
out << u" %1: %2"_s.arg(stage, -12).arg(counts.value(stage).toInt()) << Qt::endl;
}
return 0;
}
int runStages(const QString &program, const QStringList &args)
{
QCommandLineParser parser;
parser.setApplicationDescription(u"List the canonical pipeline stages"_s);
parser.addHelpOption();
parser.addOption({u"json"_s, u"Print machine-readable JSON"_s});
parser.process(QStringList{program} + args);
if (parser.isSet(u"json"_s)) {
QJsonArray arr;
for (const QString &s : JobStage::canonicalStages()) {
arr.append(s);
}
printJson(arr);
return 0;
}
QTextStream out(stdout);
for (const QString &s : JobStage::canonicalStages()) {
out << s << Qt::endl;
}
return 0;
}
int runHelp()
{
QTextStream out(stdout);
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"Running kareer with no command (or an unrecognized one) starts the GUI.\n"_s;
return 0;
}
}
bool Cli::isSubcommand(const QString &arg)
{
static const QSet<QString> subcommands{
u"add"_s, u"list"_s, u"show"_s, u"update"_s, u"stage"_s, u"delete"_s, u"stats"_s, u"stages"_s, u"help"_s,
};
return subcommands.contains(arg);
}
int Cli::run(QCoreApplication &app)
{
const QStringList allArgs = app.arguments();
const QString program = allArgs.value(0);
const QString subcommand = allArgs.value(1);
const QStringList rest = allArgs.mid(2);
if (subcommand == u"add"_s) {
return runAdd(program, rest);
}
if (subcommand == u"list"_s) {
return runList(program, rest);
}
if (subcommand == u"show"_s) {
return runShow(program, rest);
}
if (subcommand == u"update"_s) {
return runUpdate(program, rest);
}
if (subcommand == u"stage"_s) {
return runStage(program, rest);
}
if (subcommand == u"delete"_s) {
return runDelete(program, rest);
}
if (subcommand == u"stats"_s) {
return runStats(program, rest);
}
if (subcommand == u"stages"_s) {
return runStages(program, rest);
}
return runHelp();
}
+21
View File
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QString>
class QCoreApplication;
/**
* Headless command-line interface: `kareer add|list|show|update|stage|delete|stats|stages ...`.
* Lets other tools (a resume generator, a shell script) log and query
* applications without ever starting the Kirigami GUI.
*/
namespace Cli
{
/// True if the first non-option argument names one of our subcommands, i.e.
/// whether main() should route to run() instead of starting the GUI.
bool isSubcommand(const QString &arg);
/// Dispatches to the matching subcommand handler and returns a process exit code.
int run(QCoreApplication &app);
}
+36
View File
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QDate>
#include <QDateTime>
#include <QString>
/// A single job application and everything tracked about it.
struct Job {
int id = -1;
QString company;
QString title;
QString location;
QString remoteType; ///< "Onsite", "Hybrid", "Remote", or empty if unknown.
QString source; ///< Where the lead came from (referral, LinkedIn, ...).
QString url;
QDate dateApplied;
int salaryMin = -1; ///< -1 means unset.
int salaryMax = -1;
int salaryExpectation = -1;
QString currency = QStringLiteral("USD");
QString notes; ///< Free text: expectations, interview notes, etc.
QString contact;
QString stage;
QDateTime createdAt;
QDateTime updatedAt;
};
/// One recorded move from one stage to another (or from "Start" for the
/// initial application), used to build the Sankey diagram.
struct StageTransition {
int jobId = -1;
QString fromStage; ///< Empty means JobStage::Start.
QString toStage;
QDateTime changedAt;
};
+367
View File
@@ -0,0 +1,367 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "jobsdatabase.h"
#include "jobstage.h"
#include <QAtomicInteger>
#include <QDir>
#include <QFileInfo>
#include <QSqlDatabase>
#include <QSqlError>
#include <QSqlQuery>
#include <QStandardPaths>
#include <QVariant>
using namespace Qt::Literals::StringLiterals;
namespace
{
QAtomicInteger<int> s_connectionCounter{0};
QVariant salaryToVariant(int value)
{
if (value < 0) {
return {};
}
return value;
}
int salaryFromVariant(const QVariant &value)
{
return value.isNull() ? -1 : value.toInt();
}
}
JobsDatabase::JobsDatabase()
{
init(defaultPath());
}
JobsDatabase::JobsDatabase(const QString &path)
{
init(path);
}
JobsDatabase::~JobsDatabase()
{
{
QSqlDatabase db = QSqlDatabase::database(m_connectionName, false);
if (db.isValid()) {
db.close();
}
}
QSqlDatabase::removeDatabase(m_connectionName);
}
QString JobsDatabase::defaultPath()
{
const QString overridePath = qEnvironmentVariable("KAREER_DB_PATH");
if (!overridePath.isEmpty()) {
return overridePath;
}
const QString dir = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + u"/kareer"_s;
QDir().mkpath(dir);
return dir + u"/kareer.sqlite"_s;
}
void JobsDatabase::init(const QString &path)
{
m_connectionName = u"kareer_conn_%1"_s.arg(s_connectionCounter.fetchAndAddRelaxed(1));
QDir().mkpath(QFileInfo(path).absolutePath());
QSqlDatabase db = QSqlDatabase::addDatabase(u"QSQLITE"_s, m_connectionName);
db.setDatabaseName(path);
if (!db.open()) {
m_lastError = db.lastError().text();
return;
}
QSqlQuery pragma(db);
pragma.exec(u"PRAGMA foreign_keys = ON"_s);
if (!migrate()) {
return;
}
}
bool JobsDatabase::migrate()
{
QSqlDatabase db = QSqlDatabase::database(m_connectionName);
QSqlQuery query(db);
if (!query.exec(uR"(
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company TEXT NOT NULL,
title TEXT NOT NULL,
location TEXT,
remote_type TEXT,
source TEXT,
url TEXT,
date_applied TEXT,
salary_min INTEGER,
salary_max INTEGER,
salary_expectation INTEGER,
currency TEXT,
notes TEXT,
contact TEXT,
stage TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
)"_s)) {
m_lastError = query.lastError().text();
return false;
}
if (!query.exec(uR"(
CREATE TABLE IF NOT EXISTS stage_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
from_stage TEXT,
to_stage TEXT NOT NULL,
changed_at TEXT NOT NULL
)
)"_s)) {
m_lastError = query.lastError().text();
return false;
}
return true;
}
bool JobsDatabase::isOpen() const
{
return QSqlDatabase::database(m_connectionName, false).isOpen();
}
QString JobsDatabase::lastError() const
{
return m_lastError;
}
Job JobsDatabase::jobFromQuery(QSqlQuery &query) const
{
Job job;
job.id = query.value(u"id"_s).toInt();
job.company = query.value(u"company"_s).toString();
job.title = query.value(u"title"_s).toString();
job.location = query.value(u"location"_s).toString();
job.remoteType = query.value(u"remote_type"_s).toString();
job.source = query.value(u"source"_s).toString();
job.url = query.value(u"url"_s).toString();
job.dateApplied = QDate::fromString(query.value(u"date_applied"_s).toString(), Qt::ISODate);
job.salaryMin = salaryFromVariant(query.value(u"salary_min"_s));
job.salaryMax = salaryFromVariant(query.value(u"salary_max"_s));
job.salaryExpectation = salaryFromVariant(query.value(u"salary_expectation"_s));
job.currency = query.value(u"currency"_s).toString();
job.notes = query.value(u"notes"_s).toString();
job.contact = query.value(u"contact"_s).toString();
job.stage = query.value(u"stage"_s).toString();
job.createdAt = QDateTime::fromString(query.value(u"created_at"_s).toString(), Qt::ISODate);
job.updatedAt = QDateTime::fromString(query.value(u"updated_at"_s).toString(), Qt::ISODate);
return job;
}
QList<Job> JobsDatabase::allJobs() const
{
QList<Job> jobs;
QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(u"SELECT * FROM jobs ORDER BY date_applied DESC, id DESC"_s);
if (!query.exec()) {
return jobs;
}
while (query.next()) {
jobs.append(jobFromQuery(query));
}
return jobs;
}
std::optional<Job> JobsDatabase::jobById(int id) const
{
QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(u"SELECT * FROM jobs WHERE id = :id"_s);
query.bindValue(u":id"_s, id);
if (!query.exec() || !query.next()) {
return std::nullopt;
}
return jobFromQuery(query);
}
bool JobsDatabase::addJob(Job &job)
{
if (job.stage.isEmpty()) {
job.stage = QStringLiteral("Applied");
}
if (!JobStage::isValid(job.stage)) {
m_lastError = u"Unknown stage '%1'"_s.arg(job.stage);
return false;
}
QSqlDatabase db = QSqlDatabase::database(m_connectionName);
const QDateTime now = QDateTime::currentDateTimeUtc();
job.createdAt = now;
job.updatedAt = now;
QSqlQuery query(db);
query.prepare(uR"(
INSERT INTO jobs (company, title, location, remote_type, source, url, date_applied,
salary_min, salary_max, salary_expectation, currency, notes, contact,
stage, created_at, updated_at)
VALUES (:company, :title, :location, :remote_type, :source, :url, :date_applied,
:salary_min, :salary_max, :salary_expectation, :currency, :notes, :contact,
:stage, :created_at, :updated_at)
)"_s);
query.bindValue(u":company"_s, job.company);
query.bindValue(u":title"_s, job.title);
query.bindValue(u":location"_s, job.location);
query.bindValue(u":remote_type"_s, job.remoteType);
query.bindValue(u":source"_s, job.source);
query.bindValue(u":url"_s, job.url);
query.bindValue(u":date_applied"_s, job.dateApplied.isValid() ? job.dateApplied.toString(Qt::ISODate) : QVariant());
query.bindValue(u":salary_min"_s, salaryToVariant(job.salaryMin));
query.bindValue(u":salary_max"_s, salaryToVariant(job.salaryMax));
query.bindValue(u":salary_expectation"_s, salaryToVariant(job.salaryExpectation));
query.bindValue(u":currency"_s, job.currency);
query.bindValue(u":notes"_s, job.notes);
query.bindValue(u":contact"_s, job.contact);
query.bindValue(u":stage"_s, job.stage);
query.bindValue(u":created_at"_s, job.createdAt.toString(Qt::ISODate));
query.bindValue(u":updated_at"_s, job.updatedAt.toString(Qt::ISODate));
if (!query.exec()) {
m_lastError = query.lastError().text();
return false;
}
job.id = query.lastInsertId().toInt();
QSqlQuery history(db);
history.prepare(u"INSERT INTO stage_history (job_id, from_stage, to_stage, changed_at) VALUES (:job_id, NULL, :to_stage, :changed_at)"_s);
history.bindValue(u":job_id"_s, job.id);
history.bindValue(u":to_stage"_s, job.stage);
history.bindValue(u":changed_at"_s, job.createdAt.toString(Qt::ISODate));
if (!history.exec()) {
m_lastError = history.lastError().text();
return false;
}
return true;
}
bool JobsDatabase::updateJob(const Job &job)
{
QSqlDatabase db = QSqlDatabase::database(m_connectionName);
QSqlQuery query(db);
query.prepare(uR"(
UPDATE jobs SET company = :company, title = :title, location = :location,
remote_type = :remote_type, source = :source, url = :url,
date_applied = :date_applied, salary_min = :salary_min,
salary_max = :salary_max, salary_expectation = :salary_expectation,
currency = :currency, notes = :notes, contact = :contact,
updated_at = :updated_at
WHERE id = :id
)"_s);
query.bindValue(u":company"_s, job.company);
query.bindValue(u":title"_s, job.title);
query.bindValue(u":location"_s, job.location);
query.bindValue(u":remote_type"_s, job.remoteType);
query.bindValue(u":source"_s, job.source);
query.bindValue(u":url"_s, job.url);
query.bindValue(u":date_applied"_s, job.dateApplied.isValid() ? job.dateApplied.toString(Qt::ISODate) : QVariant());
query.bindValue(u":salary_min"_s, salaryToVariant(job.salaryMin));
query.bindValue(u":salary_max"_s, salaryToVariant(job.salaryMax));
query.bindValue(u":salary_expectation"_s, salaryToVariant(job.salaryExpectation));
query.bindValue(u":currency"_s, job.currency);
query.bindValue(u":notes"_s, job.notes);
query.bindValue(u":contact"_s, job.contact);
query.bindValue(u":updated_at"_s, QDateTime::currentDateTimeUtc().toString(Qt::ISODate));
query.bindValue(u":id"_s, job.id);
if (!query.exec()) {
m_lastError = query.lastError().text();
return false;
}
if (query.numRowsAffected() < 1) {
m_lastError = u"No job with id %1"_s.arg(job.id);
return false;
}
return true;
}
bool JobsDatabase::setStage(int id, const QString &newStage)
{
if (!JobStage::isValid(newStage)) {
m_lastError = u"Unknown stage '%1'"_s.arg(newStage);
return false;
}
const auto current = jobById(id);
if (!current) {
m_lastError = u"No job with id %1"_s.arg(id);
return false;
}
if (current->stage.compare(newStage, Qt::CaseInsensitive) == 0) {
return true;
}
QSqlDatabase db = QSqlDatabase::database(m_connectionName);
const QDateTime now = QDateTime::currentDateTimeUtc();
QSqlQuery query(db);
query.prepare(u"UPDATE jobs SET stage = :stage, updated_at = :updated_at WHERE id = :id"_s);
query.bindValue(u":stage"_s, newStage);
query.bindValue(u":updated_at"_s, now.toString(Qt::ISODate));
query.bindValue(u":id"_s, id);
if (!query.exec()) {
m_lastError = query.lastError().text();
return false;
}
QSqlQuery history(db);
history.prepare(u"INSERT INTO stage_history (job_id, from_stage, to_stage, changed_at) VALUES (:job_id, :from_stage, :to_stage, :changed_at)"_s);
history.bindValue(u":job_id"_s, id);
history.bindValue(u":from_stage"_s, current->stage);
history.bindValue(u":to_stage"_s, newStage);
history.bindValue(u":changed_at"_s, now.toString(Qt::ISODate));
if (!history.exec()) {
m_lastError = history.lastError().text();
return false;
}
return true;
}
bool JobsDatabase::deleteJob(int id)
{
QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(u"DELETE FROM jobs WHERE id = :id"_s);
query.bindValue(u":id"_s, id);
if (!query.exec()) {
m_lastError = query.lastError().text();
return false;
}
if (query.numRowsAffected() < 1) {
m_lastError = u"No job with id %1"_s.arg(id);
return false;
}
return true;
}
QList<StageTransition> JobsDatabase::stageTransitions() const
{
QList<StageTransition> transitions;
QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(u"SELECT job_id, from_stage, to_stage, changed_at FROM stage_history ORDER BY changed_at ASC, id ASC"_s);
if (!query.exec()) {
return transitions;
}
while (query.next()) {
StageTransition t;
t.jobId = query.value(0).toInt();
t.fromStage = query.value(1).toString();
t.toStage = query.value(2).toString();
t.changedAt = QDateTime::fromString(query.value(3).toString(), Qt::ISODate);
transitions.append(t);
}
return transitions;
}
+62
View File
@@ -0,0 +1,62 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include "job.h"
#include <QList>
#include <QString>
#include <optional>
class QSqlDatabase;
/**
* SQLite-backed storage for job applications and their stage history.
*
* Every JobsDatabase instance owns its own named QSqlDatabase connection
* (Qt's SQL connections are identified by name, not by object identity),
* so multiple instances - e.g. in tests - never collide.
*/
class JobsDatabase
{
public:
JobsDatabase();
explicit JobsDatabase(const QString &path);
~JobsDatabase();
JobsDatabase(const JobsDatabase &) = delete;
JobsDatabase &operator=(const JobsDatabase &) = delete;
/// Default location: $XDG_DATA_HOME/kareer/kareer.sqlite, overridable
/// with the KAREER_DB_PATH environment variable (used by autotests).
static QString defaultPath();
bool isOpen() const;
QString lastError() const;
QList<Job> allJobs() const;
std::optional<Job> jobById(int id) const;
/// Inserts a new job. On success, job.id/createdAt/updatedAt are filled
/// in and an initial Start -> job.stage transition is recorded.
bool addJob(Job &job);
/// Updates every field except stage (use setStage for that, so every
/// stage change is captured in the history).
bool updateJob(const Job &job);
/// Moves a job to newStage, recording the transition. A no-op (but still
/// successful) if the job is already in newStage.
bool setStage(int id, const QString &newStage);
bool deleteJob(int id);
QList<StageTransition> stageTransitions() const;
private:
void init(const QString &path);
bool migrate();
Job jobFromQuery(class QSqlQuery &query) const;
QString m_connectionName;
QString m_lastError;
};
+206
View File
@@ -0,0 +1,206 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "jobsmodel.h"
#include "jobstage.h"
using namespace Qt::Literals::StringLiterals;
JobsModel::JobsModel(QObject *parent)
: QAbstractListModel(parent)
{
refresh();
}
void JobsModel::refresh()
{
beginResetModel();
m_jobs = m_db.allJobs();
endResetModel();
Q_EMIT countChanged();
}
int JobsModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) {
return 0;
}
return m_jobs.size();
}
QVariant JobsModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_jobs.size()) {
return {};
}
const Job &job = m_jobs.at(index.row());
switch (role) {
case IdRole:
return job.id;
case CompanyRole:
return job.company;
case TitleRole:
return job.title;
case LocationRole:
return job.location;
case RemoteTypeRole:
return job.remoteType;
case SourceRole:
return job.source;
case UrlRole:
return job.url;
case DateAppliedRole:
return job.dateApplied;
case SalaryMinRole:
return job.salaryMin;
case SalaryMaxRole:
return job.salaryMax;
case SalaryExpectationRole:
return job.salaryExpectation;
case CurrencyRole:
return job.currency;
case NotesRole:
return job.notes;
case ContactRole:
return job.contact;
case StageRole:
return job.stage;
case StageColorRole:
return JobStage::color(job.stage);
case CreatedAtRole:
return job.createdAt;
case UpdatedAtRole:
return job.updatedAt;
default:
return {};
}
}
QHash<int, QByteArray> JobsModel::roleNames() const
{
return {
{IdRole, "jobId"},
{CompanyRole, "company"},
{TitleRole, "title"},
{LocationRole, "location"},
{RemoteTypeRole, "remoteType"},
{SourceRole, "source"},
{UrlRole, "url"},
{DateAppliedRole, "dateApplied"},
{SalaryMinRole, "salaryMin"},
{SalaryMaxRole, "salaryMax"},
{SalaryExpectationRole, "salaryExpectation"},
{CurrencyRole, "currency"},
{NotesRole, "notes"},
{ContactRole, "contact"},
{StageRole, "stage"},
{StageColorRole, "stageColor"},
{CreatedAtRole, "createdAt"},
{UpdatedAtRole, "updatedAt"},
};
}
QStringList JobsModel::stages() const
{
return JobStage::canonicalStages();
}
Job JobsModel::jobFromMap(const QVariantMap &fields)
{
Job job;
job.company = fields.value(u"company"_s).toString();
job.title = fields.value(u"title"_s).toString();
job.location = fields.value(u"location"_s).toString();
job.remoteType = fields.value(u"remoteType"_s).toString();
job.source = fields.value(u"source"_s).toString();
job.url = fields.value(u"url"_s).toString();
const QVariant dateValue = fields.value(u"dateApplied"_s);
job.dateApplied = dateValue.canConvert<QDate>() ? dateValue.toDate() : QDate::fromString(dateValue.toString(), Qt::ISODate);
job.salaryMin = fields.value(u"salaryMin"_s, -1).toInt();
job.salaryMax = fields.value(u"salaryMax"_s, -1).toInt();
job.salaryExpectation = fields.value(u"salaryExpectation"_s, -1).toInt();
job.currency = fields.value(u"currency"_s, u"USD"_s).toString();
if (job.currency.isEmpty()) {
job.currency = u"USD"_s;
}
job.notes = fields.value(u"notes"_s).toString();
job.contact = fields.value(u"contact"_s).toString();
job.stage = fields.value(u"stage"_s).toString();
return job;
}
QVariantMap JobsModel::mapFromJob(const Job &job)
{
return {
{u"id"_s, job.id},
{u"company"_s, job.company},
{u"title"_s, job.title},
{u"location"_s, job.location},
{u"remoteType"_s, job.remoteType},
{u"source"_s, job.source},
{u"url"_s, job.url},
{u"dateApplied"_s, job.dateApplied},
{u"salaryMin"_s, job.salaryMin},
{u"salaryMax"_s, job.salaryMax},
{u"salaryExpectation"_s, job.salaryExpectation},
{u"currency"_s, job.currency},
{u"notes"_s, job.notes},
{u"contact"_s, job.contact},
{u"stage"_s, job.stage},
{u"createdAt"_s, job.createdAt},
{u"updatedAt"_s, job.updatedAt},
};
}
QVariantMap JobsModel::jobData(int id) const
{
const auto job = m_db.jobById(id);
if (!job) {
return {};
}
return mapFromJob(*job);
}
bool JobsModel::addJob(const QVariantMap &fields)
{
Job job = jobFromMap(fields);
const bool ok = m_db.addJob(job);
if (ok) {
refresh();
}
return ok;
}
bool JobsModel::updateJob(int id, const QVariantMap &fields)
{
Job job = jobFromMap(fields);
job.id = id;
const bool ok = m_db.updateJob(job);
if (ok) {
refresh();
}
return ok;
}
bool JobsModel::setStage(int id, const QString &stage)
{
const bool ok = m_db.setStage(id, stage);
if (ok) {
refresh();
}
return ok;
}
bool JobsModel::removeJob(int id)
{
const bool ok = m_db.deleteJob(id);
if (ok) {
refresh();
}
return ok;
}
QString JobsModel::lastError() const
{
return m_db.lastError();
}
+77
View File
@@ -0,0 +1,77 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include "job.h"
#include "jobsdatabase.h"
#include <QAbstractListModel>
#include <QList>
#include <QQmlEngine>
/**
* List of all job applications, newest first, backed by JobsDatabase.
* QML reads jobs through model roles and writes through the invokable
* methods, which go straight to the database and then refresh in place.
*/
class JobsModel : public QAbstractListModel
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(int count READ rowCount NOTIFY countChanged)
Q_PROPERTY(QStringList stages READ stages CONSTANT)
public:
enum Roles {
IdRole = Qt::UserRole + 1,
CompanyRole,
TitleRole,
LocationRole,
RemoteTypeRole,
SourceRole,
UrlRole,
DateAppliedRole,
SalaryMinRole,
SalaryMaxRole,
SalaryExpectationRole,
CurrencyRole,
NotesRole,
ContactRole,
StageRole,
StageColorRole,
CreatedAtRole,
UpdatedAtRole,
};
Q_ENUM(Roles)
explicit JobsModel(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;
/// Full record for one job, for prefilling the edit dialog.
Q_INVOKABLE QVariantMap jobData(int id) const;
Q_INVOKABLE bool 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);
Q_INVOKABLE QString lastError() const;
public Q_SLOTS:
void refresh();
Q_SIGNALS:
void countChanged();
private:
static Job jobFromMap(const QVariantMap &fields);
static QVariantMap mapFromJob(const Job &job);
JobsDatabase m_db;
QList<Job> m_jobs;
};
+93
View File
@@ -0,0 +1,93 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "jobstage.h"
#include <QHash>
namespace JobStage
{
QStringList canonicalStages()
{
static const QStringList stages{
QStringLiteral("Applied"),
QStringLiteral("Screening"),
QStringLiteral("Interview"),
QStringLiteral("Onsite"),
QStringLiteral("Offer"),
QStringLiteral("Accepted"),
QStringLiteral("Rejected"),
QStringLiteral("Withdrawn"),
QStringLiteral("Ghosted"),
};
return stages;
}
bool isValid(const QString &stage)
{
return canonicalStages().contains(stage, Qt::CaseInsensitive);
}
int column(const QString &stage)
{
static const QHash<QString, int> columns{
{QStringLiteral("Applied"), 1},
{QStringLiteral("Screening"), 2},
{QStringLiteral("Interview"), 3},
{QStringLiteral("Onsite"), 4},
{QStringLiteral("Offer"), 5},
{QStringLiteral("Accepted"), 6},
{QStringLiteral("Rejected"), 6},
{QStringLiteral("Withdrawn"), 6},
{QStringLiteral("Ghosted"), 6},
};
if (stage == QLatin1String(Start)) {
return 0;
}
return columns.value(stage, 1);
}
int orderInColumn(const QString &stage)
{
static const QHash<QString, int> order{
{QStringLiteral("Accepted"), 0},
{QStringLiteral("Offer"), 1},
{QStringLiteral("Onsite"), 2},
{QStringLiteral("Interview"), 3},
{QStringLiteral("Screening"), 4},
{QStringLiteral("Applied"), 5},
{QStringLiteral("Rejected"), 6},
{QStringLiteral("Withdrawn"), 7},
{QStringLiteral("Ghosted"), 8},
};
if (stage == QLatin1String(Start)) {
return -1;
}
return order.value(stage, 99);
}
QColor color(const QString &stage)
{
static const QHash<QString, QColor> colors{
{QStringLiteral("Applied"), QColor(0x3d, 0xae, 0xe9)},
{QStringLiteral("Screening"), QColor(0x2e, 0xc4, 0xb6)},
{QStringLiteral("Interview"), QColor(0x9b, 0x59, 0xb6)},
{QStringLiteral("Onsite"), QColor(0x8e, 0x44, 0xad)},
{QStringLiteral("Offer"), QColor(0xf3, 0x9c, 0x12)},
{QStringLiteral("Accepted"), QColor(0x27, 0xae, 0x60)},
{QStringLiteral("Rejected"), QColor(0xe7, 0x4c, 0x3c)},
{QStringLiteral("Withdrawn"), QColor(0x95, 0xa5, 0xa6)},
{QStringLiteral("Ghosted"), QColor(0x7f, 0x8c, 0x8d)},
};
if (stage == QLatin1String(Start)) {
return QColor(0x5c, 0x63, 0x70);
}
return colors.value(stage, QColor(0x5c, 0x63, 0x70));
}
bool isTerminal(const QString &stage)
{
return stage == QLatin1String("Accepted") || stage == QLatin1String("Rejected") || stage == QLatin1String("Withdrawn")
|| stage == QLatin1String("Ghosted");
}
}
+35
View File
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QColor>
#include <QString>
#include <QStringList>
/**
* The fixed set of stages an application can be in. Kept as a small closed
* vocabulary (rather than free text) so the Sankey diagram's columns and
* stacking order stay stable no matter what data is loaded.
*/
namespace JobStage
{
/// Stages in pipeline order. "Start" is a synthetic node (not a real stage,
/// never stored on a Job) representing "before the first recorded stage".
constexpr auto Start = "Start";
QStringList canonicalStages();
bool isValid(const QString &stage);
/// Sankey column index. Start = 0; Applied..Offer walk the funnel; the three
/// terminal outcomes (Accepted/Rejected/Withdrawn/Ghosted) share the last
/// column so a rejection right after Applied is still a valid (longer) link.
int column(const QString &stage);
/// Stacking order of nodes within a column (top to bottom).
int orderInColumn(const QString &stage);
/// Stable color used for both the node box and its outgoing links.
QColor color(const QString &stage);
bool isTerminal(const QString &stage);
}
+96
View File
@@ -0,0 +1,96 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "kareer-version.h"
#include "clicommands.h"
#include <KAboutData>
#include <KCrash>
#include <KIconTheme>
#include <KLocalizedQmlContext>
#include <KLocalizedString>
#include <QApplication>
#include <QCommandLineParser>
#include <QCoreApplication>
#include <QIcon>
#include <QQmlApplicationEngine>
#include <QQuickStyle>
using namespace Qt::Literals::StringLiterals;
// Filter out a couple of well-known benign framework artifacts rather than
// spamming every run. Everything else is passed through untouched.
static QtMessageHandler s_defaultMessageHandler = nullptr;
static void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &message)
{
// Qt Quick emits this while Kirigami's PageRow incubates pages. It fires even
// for a trivial empty page, is harmless, and cannot be avoided from app code.
if (message.contains(QLatin1String("was not placed in the graphics scene"))) {
return;
}
// Malformed path data in third-party icon SVGs (system/app icon themes) is not
// actionable from here; drop the noise rather than spam every render.
if (context.category && qstrcmp(context.category, "qt.svg") == 0) {
return;
}
// Qt's Wayland integration tries to self-register with xdg-desktop-portal for
// optional desktop features (global shortcuts, background). Kareer doesn't use
// any of those, and it fires harmlessly on hosts where portal app-info
// resolution is finicky.
if (message.contains(QLatin1String("Failed to register with host portal"))) {
return;
}
if (s_defaultMessageHandler) {
s_defaultMessageHandler(type, context, message);
}
}
int main(int argc, char *argv[])
{
s_defaultMessageHandler = qInstallMessageHandler(messageHandler);
if (argc >= 2 && Cli::isSubcommand(QString::fromLocal8Bit(argv[1]))) {
QCoreApplication app(argc, argv);
return Cli::run(app);
}
KIconTheme::initTheme();
QApplication app(argc, argv);
KLocalizedString::setApplicationDomain(QByteArrayLiteral("kareer"));
QCoreApplication::setOrganizationName(u"toservetheking"_s);
if (qEnvironmentVariableIsEmpty("QT_QUICK_CONTROLS_STYLE")) {
QQuickStyle::setStyle(u"org.kde.desktop"_s);
QQuickStyle::setFallbackStyle(u"Fusion"_s);
}
KAboutData aboutData(u"kareer"_s,
i18nc("@title", "Kareer"),
QStringLiteral(KAREER_VERSION_STRING),
i18n("Track your job applications"),
KAboutLicense::GPL_V3,
i18n("© 2026 Kareer contributors"));
aboutData.addAuthor(u"toservetheking"_s, i18nc("@label", "Author"), u"[email protected]"_s);
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)));
KCrash::initialize();
QCommandLineParser parser;
aboutData.setupCommandLine(&parser);
parser.process(app);
aboutData.processCommandLine(&parser);
QQmlApplicationEngine engine;
KLocalization::setupLocalizedContext(&engine);
engine.loadFromModule("io.github.toservetheking.Kareer", u"Main"_s);
if (engine.rootObjects().isEmpty()) {
return -1;
}
return app.exec();
}
+216
View File
@@ -0,0 +1,216 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.formcard as FormCard
import io.github.toservetheking.Kareer
FormCard.FormCardDialog {
id: root
required property JobsModel jobsModel
property int editingJobId: -1
title: editingJobId < 0 ? i18nc("@title:dialog", "Add Application") : i18nc("@title:dialog", "Edit Application")
standardButtons: QQC2.Dialog.Save | QQC2.Dialog.Cancel
function openForAdd(): void {
editingJobId = -1;
companyField.text = "";
titleField.text = "";
locationField.text = "";
remoteCombo.currentIndex = 0;
sourceField.text = "";
urlField.text = "";
dateField.value = new Date();
salaryMinField.value = 0;
salaryMaxField.value = 0;
salaryExpectationField.value = 0;
currencyField.text = "USD";
contactField.text = "";
notesField.text = "";
const stages = root.jobsModel.stages;
stageCombo.currentIndex = stages.indexOf("Applied");
errorLabel.text = "";
root.open();
}
function openForEdit(id: int): void {
editingJobId = id;
const data = root.jobsModel.jobData(id);
companyField.text = data.company;
titleField.text = data.title;
locationField.text = data.location;
remoteCombo.currentIndex = Math.max(0, remoteCombo.model.indexOf(data.remoteType));
sourceField.text = data.source;
urlField.text = data.url;
dateField.value = data.dateApplied;
salaryMinField.value = data.salaryMin > 0 ? data.salaryMin : 0;
salaryMaxField.value = data.salaryMax > 0 ? data.salaryMax : 0;
salaryExpectationField.value = data.salaryExpectation > 0 ? data.salaryExpectation : 0;
currencyField.text = data.currency;
contactField.text = data.contact;
notesField.text = data.notes;
const stages = root.jobsModel.stages;
stageCombo.currentIndex = Math.max(0, stages.indexOf(data.stage));
errorLabel.text = "";
root.open();
}
onAccepted: {
const fields = {
company: companyField.text,
title: titleField.text,
location: locationField.text,
remoteType: remoteCombo.currentIndex === 0 ? "" : remoteCombo.currentText,
source: sourceField.text,
url: urlField.text,
dateApplied: dateField.value,
salaryMin: salaryMinField.value > 0 ? salaryMinField.value : -1,
salaryMax: salaryMaxField.value > 0 ? salaryMaxField.value : -1,
salaryExpectation: salaryExpectationField.value > 0 ? salaryExpectationField.value : -1,
currency: currencyField.text,
contact: contactField.text,
notes: notesField.text,
stage: stageCombo.currentText,
};
const ok = root.editingJobId < 0 ? root.jobsModel.addJob(fields) : root.jobsModel.updateJob(root.editingJobId, fields);
if (!ok) {
errorLabel.text = root.jobsModel.lastError();
root.open();
}
}
FormCard.FormCard {
FormCard.FormTextFieldDelegate {
id: companyField
label: i18nc("@label:textbox", "Company")
}
FormCard.FormDelegateSeparator {}
FormCard.FormTextFieldDelegate {
id: titleField
label: i18nc("@label:textbox", "Job Title")
}
FormCard.FormDelegateSeparator {}
FormCard.FormTextFieldDelegate {
id: locationField
label: i18nc("@label:textbox", "Location")
}
FormCard.FormDelegateSeparator {}
FormCard.FormComboBoxDelegate {
id: remoteCombo
text: i18nc("@label:listbox", "Remote Type")
model: ["Unspecified", "Onsite", "Hybrid", "Remote"]
}
}
FormCard.FormCard {
FormCard.FormComboBoxDelegate {
id: stageCombo
text: i18nc("@label:listbox", "Stage")
model: root.jobsModel.stages
}
FormCard.FormDelegateSeparator {}
FormCard.FormHeader {
title: i18nc("@title:group", "Date Applied")
}
FormCard.FormDateTimeDelegate {
id: dateField
dateTimeDisplay: FormCard.FormDateTimeDelegate.DateTimeDisplay.Date
}
}
FormCard.FormCard {
FormCard.FormSpinBoxDelegate {
id: salaryMinField
label: i18nc("@label:spinbox", "Salary Range Minimum")
from: 0
to: 5000000
stepSize: 1000
}
FormCard.FormDelegateSeparator {}
FormCard.FormSpinBoxDelegate {
id: salaryMaxField
label: i18nc("@label:spinbox", "Salary Range Maximum")
from: 0
to: 5000000
stepSize: 1000
}
FormCard.FormDelegateSeparator {}
FormCard.FormSpinBoxDelegate {
id: salaryExpectationField
label: i18nc("@label:spinbox", "Your Salary Expectation")
from: 0
to: 5000000
stepSize: 1000
}
FormCard.FormDelegateSeparator {}
FormCard.FormTextFieldDelegate {
id: currencyField
label: i18nc("@label:textbox", "Currency")
}
}
FormCard.FormCard {
FormCard.FormTextFieldDelegate {
id: sourceField
label: i18nc("@label:textbox", "Source")
placeholderText: i18nc("@info:placeholder", "Referral, LinkedIn, company site...")
}
FormCard.FormDelegateSeparator {}
FormCard.FormTextFieldDelegate {
id: urlField
label: i18nc("@label:textbox", "Job Posting URL")
}
FormCard.FormDelegateSeparator {}
FormCard.FormTextFieldDelegate {
id: contactField
label: i18nc("@label:textbox", "Contact")
}
FormCard.FormDelegateSeparator {}
FormCard.FormTextAreaDelegate {
id: notesField
label: i18nc("@label:textbox", "Notes / Expectations")
}
}
FormCard.FormCard {
visible: root.editingJobId >= 0
FormCard.FormButtonDelegate {
text: i18nc("@action:button", "Delete Application")
icon.name: "edit-delete"
onClicked: deleteConfirmDialog.open()
}
}
Kirigami.InlineMessage {
id: errorLabel
Layout.fillWidth: true
Layout.margins: Kirigami.Units.smallSpacing
type: Kirigami.MessageType.Error
visible: text.length > 0
}
Kirigami.PromptDialog {
id: deleteConfirmDialog
title: i18nc("@title", "Delete Application")
subtitle: i18nc("@info", "Are you sure you want to delete this application? This cannot be undone.")
standardButtons: QQC2.Dialog.Cancel
customFooterActions: [
Kirigami.Action {
text: i18nc("@action:button", "Delete")
icon.name: "edit-delete"
onTriggered: {
root.jobsModel.removeJob(root.editingJobId);
deleteConfirmDialog.close();
root.close();
}
}
]
}
}
+89
View File
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.kitemmodels as KItemModels
import org.kde.kirigamiaddons.delegates as Delegates
import io.github.toservetheking.Kareer
Kirigami.ScrollablePage {
id: root
required property JobsModel jobsModel
required property var editDialog
title: i18nc("@title", "Applications")
titleDelegate: Kirigami.SearchField {
Layout.fillWidth: true
onTextChanged: filteredJobs.filterString = text
}
actions: [
Kirigami.Action {
text: i18nc("@action:button", "Add Application")
icon.name: "list-add"
onTriggered: root.editDialog.openForAdd()
}
]
KItemModels.KSortFilterProxyModel {
id: filteredJobs
sourceModel: root.jobsModel
filterRoleName: "company"
filterCaseSensitivity: Qt.CaseInsensitive
}
ListView {
id: jobList
model: filteredJobs
currentIndex: -1
delegate: Delegates.RoundedItemDelegate {
id: jobDelegate
required property int index
required property int jobId
required property string company
required property string title
required property string stage
required property var dateApplied
required property int salaryMin
required property int salaryMax
required property string currency
text: jobDelegate.company
contentItem: Delegates.SubtitleContentItem {
itemDelegate: jobDelegate
subtitle: {
const parts = [jobDelegate.title, jobDelegate.stage];
if (jobDelegate.dateApplied) {
parts.push(Qt.formatDate(jobDelegate.dateApplied, "yyyy-MM-dd"));
}
if (jobDelegate.salaryMin >= 0 || jobDelegate.salaryMax >= 0) {
let salary = jobDelegate.currency + " ";
salary += jobDelegate.salaryMin >= 0 ? jobDelegate.salaryMin : "?";
salary += "";
salary += jobDelegate.salaryMax >= 0 ? jobDelegate.salaryMax : "?";
parts.push(salary);
}
return parts.join(" · ");
}
}
onClicked: root.editDialog.openForEdit(jobDelegate.jobId)
}
Kirigami.PlaceholderMessage {
anchors.centerIn: parent
width: parent.width - Kirigami.Units.gridUnit * 4
visible: jobList.count === 0
icon.name: "office-address-book-symbolic"
text: i18n("No applications yet")
explanation: i18n("Use the Add Application button to log your first one.")
}
}
}
+80
View File
@@ -0,0 +1,80 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls as QQC2
import org.kde.kirigami as Kirigami
import io.github.toservetheking.Kareer
Kirigami.ScrollablePage {
id: root
required property JobsModel jobsModel
title: i18nc("@title", "Dashboard")
StatsModel {
id: statsModel
}
Connections {
target: root.jobsModel
function onCountChanged() {
statsModel.refresh();
sankeyDiagram.refresh();
}
}
Component.onCompleted: statsModel.refresh()
ColumnLayout {
width: root.width
spacing: Kirigami.Units.largeSpacing
GridLayout {
Layout.fillWidth: true
columns: root.width > Kirigami.Units.gridUnit * 30 ? 4 : 2
columnSpacing: Kirigami.Units.largeSpacing
rowSpacing: Kirigami.Units.largeSpacing
Repeater {
model: [
{label: i18n("Total Applications"), value: String(statsModel.totalApplications)},
{label: i18n("Active"), value: String(statsModel.activeApplications)},
{label: i18n("Offers"), value: String(statsModel.offerCount)},
{label: i18n("Response Rate"), value: Math.round(statsModel.responseRate) + "%"},
]
delegate: Kirigami.AbstractCard {
id: statCard
required property var modelData
Layout.fillWidth: true
contentItem: ColumnLayout {
spacing: Kirigami.Units.smallSpacing
Kirigami.Heading {
level: 2
text: statCard.modelData.value
}
QQC2.Label {
text: statCard.modelData.label
opacity: 0.7
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
}
}
}
}
Kirigami.Heading {
level: 3
text: i18nc("@title:group", "Pipeline")
}
SankeyDiagram {
id: sankeyDiagram
Layout.fillWidth: true
Layout.preferredHeight: Kirigami.Units.gridUnit * 20
}
}
}
+47
View File
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma ComponentBehavior: Bound
import QtQuick
import org.kde.kirigami as Kirigami
import io.github.toservetheking.Kareer
Kirigami.ApplicationWindow {
id: root
title: i18nc("@title:window", "Kareer")
minimumWidth: Kirigami.Units.gridUnit * 32
minimumHeight: Kirigami.Units.gridUnit * 24
width: Kirigami.Units.gridUnit * 50
height: Kirigami.Units.gridUnit * 36
JobsModel {
id: jobsModel
}
ApplicationEditDialog {
id: editDialog
jobsModel: jobsModel
}
pageStack.defaultColumnWidth: Kirigami.Units.gridUnit * 22
pageStack.globalToolBar.style: Kirigami.ApplicationHeaderStyle.ToolBar
// Two static columns (applications + dashboard) - no dynamic page pushing.
pageStack.initialPage: [applicationsComponent, dashboardComponent]
Component {
id: applicationsComponent
ApplicationsPage {
jobsModel: jobsModel
editDialog: editDialog
}
}
Component {
id: dashboardComponent
DashboardPage {
jobsModel: jobsModel
}
}
}
+89
View File
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Shapes
import QtQuick.Controls as QQC2
import org.kde.kirigami as Kirigami
import io.github.toservetheking.Kareer
// All the layout math (columns, stacking, ribbon paths) lives in SankeyModel;
// this component only draws the rectangles and PathSvg shapes it hands back.
Item {
id: root
readonly property bool empty: sankeyModel.empty
SankeyModel {
id: sankeyModel
}
function refresh(): void {
if (root.width > 0 && root.height > 0) {
sankeyModel.relayout(root.width, root.height);
}
}
onWidthChanged: refresh()
onHeightChanged: refresh()
Component.onCompleted: refresh()
Kirigami.PlaceholderMessage {
anchors.centerIn: parent
width: parent.width - Kirigami.Units.gridUnit * 4
visible: root.empty
icon.name: "office-chart-line-symbolic"
text: i18n("No applications yet")
explanation: i18n("Once you add applications, their pipeline will appear here.")
}
Repeater {
model: sankeyModel.links
delegate: Shape {
required property var modelData
asynchronous: true
ShapePath {
fillColor: modelData.color
strokeColor: "transparent"
PathSvg {
path: modelData.pathData
}
}
}
}
Repeater {
model: sankeyModel.nodes
delegate: Rectangle {
id: nodeDelegate
required property var modelData
x: modelData.x
y: modelData.y
width: modelData.width
height: modelData.height
radius: 2
color: modelData.color
QQC2.ToolTip.visible: nodeMouse.containsMouse
QQC2.ToolTip.text: `${modelData.label} (${modelData.value})`
MouseArea {
id: nodeMouse
anchors.fill: parent
hoverEnabled: true
}
Kirigami.Heading {
level: 5
anchors.left: parent.right
anchors.leftMargin: Kirigami.Units.smallSpacing
anchors.verticalCenter: parent.verticalCenter
width: nodeDelegate.modelData.labelWidth
text: nodeDelegate.modelData.label
visible: nodeDelegate.height >= Kirigami.Units.gridUnit
elide: Text.ElideRight
}
}
}
}
+261
View File
@@ -0,0 +1,261 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "sankeymodel.h"
#include "jobstage.h"
#include <KLocalizedString>
#include <QHash>
#include <QPair>
#include <QSet>
#include <QVariantMap>
#include <algorithm>
#include <iterator>
using namespace Qt::Literals::StringLiterals;
namespace
{
QString num(qreal v)
{
return QString::number(v, 'f', 2);
}
QString point(qreal x, qreal y)
{
return num(x) + u","_s + num(y);
}
struct NodeInfo {
QString stage;
int column = 0;
int order = 0;
int value = 0;
qreal x = 0;
qreal y = 0;
qreal width = 0;
qreal height = 0;
};
}
SankeyModel::SankeyModel(QObject *parent)
: QObject(parent)
{
}
QVariantList SankeyModel::nodes() const
{
return m_nodes;
}
QVariantList SankeyModel::links() const
{
return m_links;
}
bool SankeyModel::isEmpty() const
{
return m_nodes.isEmpty();
}
void SankeyModel::relayout(qreal width, qreal height)
{
m_nodes.clear();
m_links.clear();
if (width <= 0 || height <= 0) {
Q_EMIT changed();
return;
}
const QList<StageTransition> transitions = m_db.stageTransitions();
QHash<QPair<QString, QString>, int> counts;
for (const StageTransition &t : transitions) {
const QString from = t.fromStage.isEmpty() ? QString::fromLatin1(JobStage::Start) : t.fromStage;
counts[{from, t.toStage}] += 1;
}
if (counts.isEmpty()) {
Q_EMIT changed();
return;
}
QHash<QString, int> inbound;
QHash<QString, int> outbound;
QSet<QString> stageNames;
for (auto it = counts.constBegin(); it != counts.constEnd(); ++it) {
const QString &from = it.key().first;
const QString &to = it.key().second;
outbound[from] += it.value();
inbound[to] += it.value();
stageNames.insert(from);
stageNames.insert(to);
}
QList<NodeInfo> nodeList;
nodeList.reserve(stageNames.size());
for (const QString &stage : std::as_const(stageNames)) {
NodeInfo n;
n.stage = stage;
n.column = JobStage::column(stage);
n.order = JobStage::orderInColumn(stage);
n.value = qMax(inbound.value(stage, 0), outbound.value(stage, 0));
nodeList.append(n);
}
std::sort(nodeList.begin(), nodeList.end(), [](const NodeInfo &a, const NodeInfo &b) {
if (a.column != b.column) {
return a.column < b.column;
}
return a.order < b.order;
});
QHash<int, QList<int>> columnIndices;
int maxColumn = 0;
for (int i = 0; i < nodeList.size(); ++i) {
columnIndices[nodeList.at(i).column].append(i);
maxColumn = qMax(maxColumn, nodeList.at(i).column);
}
constexpr qreal nodeWidth = 16.0;
constexpr qreal padding = 10.0;
// A single vertical scale is shared by every column: the column with the
// largest total flow determines it, so no column can overflow the
// available height.
qreal scale = 1.0;
bool haveScale = false;
for (auto it = columnIndices.constBegin(); it != columnIndices.constEnd(); ++it) {
int total = 0;
for (int idx : it.value()) {
total += nodeList.at(idx).value;
}
if (total <= 0) {
continue;
}
const qreal gaps = padding * qMax(0, it.value().size() - 1);
const qreal available = qMax<qreal>(1.0, height - gaps);
const qreal candidate = available / total;
if (!haveScale || candidate < scale) {
scale = candidate;
haveScale = true;
}
}
if (!haveScale) {
scale = 1.0;
}
for (auto it = columnIndices.begin(); it != columnIndices.end(); ++it) {
const QList<int> &idxs = it.value();
qreal totalHeight = 0;
for (int idx : idxs) {
totalHeight += qMax<qreal>(nodeList.at(idx).value * scale, 2.0);
}
const qreal gaps = padding * qMax(0, idxs.size() - 1);
const qreal startY = qMax<qreal>(0.0, (height - totalHeight - gaps) / 2.0);
const qreal x = maxColumn > 0 ? (it.key() * (width - nodeWidth) / maxColumn) : 0.0;
qreal cursorY = startY;
for (int idx : idxs) {
NodeInfo &n = nodeList[idx];
n.x = x;
n.y = cursorY;
n.width = nodeWidth;
n.height = qMax<qreal>(n.value * scale, 2.0);
cursorY += n.height + padding;
}
}
QHash<QString, int> nodeIndexByStage;
for (int i = 0; i < nodeList.size(); ++i) {
nodeIndexByStage.insert(nodeList.at(i).stage, i);
}
QHash<QString, qreal> sourceCursor;
QHash<QString, qreal> targetCursor;
for (const NodeInfo &n : std::as_const(nodeList)) {
sourceCursor.insert(n.stage, n.y);
targetCursor.insert(n.stage, n.y);
}
QList<QPair<QString, QString>> linkKeys = counts.keys();
std::sort(linkKeys.begin(), linkKeys.end(), [&](const QPair<QString, QString> &a, const QPair<QString, QString> &b) {
const int aFrom = nodeIndexByStage.value(a.first);
const int bFrom = nodeIndexByStage.value(b.first);
if (aFrom != bFrom) {
return aFrom < bFrom;
}
return nodeIndexByStage.value(a.second) < nodeIndexByStage.value(b.second);
});
for (const auto &key : std::as_const(linkKeys)) {
const QString &from = key.first;
const QString &to = key.second;
const int value = counts.value(key);
const qreal thickness = qMax<qreal>(value * scale, 1.5);
const NodeInfo &fromNode = nodeList.at(nodeIndexByStage.value(from));
const NodeInfo &toNode = nodeList.at(nodeIndexByStage.value(to));
const qreal y0Top = sourceCursor.value(from);
const qreal y0Bottom = y0Top + thickness;
sourceCursor[from] = y0Bottom;
const qreal y1Top = targetCursor.value(to);
const qreal y1Bottom = y1Top + thickness;
targetCursor[to] = y1Bottom;
const qreal x0 = fromNode.x + fromNode.width;
const qreal x1 = toNode.x;
const qreal midX = (x0 + x1) / 2.0;
const QString path = u"M"_s + point(x0, y0Top) + u" C"_s + point(midX, y0Top) + u" "_s + point(midX, y1Top) + u" "_s + point(x1, y1Top)
+ u" L"_s + point(x1, y1Bottom) + u" C"_s + point(midX, y1Bottom) + u" "_s + point(midX, y0Bottom) + u" "_s + point(x0, y0Bottom) + u" Z"_s;
QColor linkColor = JobStage::color(from);
linkColor.setAlphaF(0.5f);
m_links.append(QVariantMap{
{u"fromStage"_s, from},
{u"toStage"_s, to},
{u"value"_s, value},
{u"pathData"_s, path},
{u"color"_s, linkColor},
});
}
// Labels are drawn to the right of each node. Without a bound, a long
// label (e.g. "Applications") can run into the next column's node and
// its own label. Cap each node's label to the gap before the next
// distinct column actually in use (skipping empty columns), so text
// elides instead of overlapping.
QList<qreal> columnStarts;
for (const NodeInfo &n : std::as_const(nodeList)) {
if (!columnStarts.contains(n.x)) {
columnStarts.append(n.x);
}
}
std::sort(columnStarts.begin(), columnStarts.end());
for (const NodeInfo &n : std::as_const(nodeList)) {
const QString label = n.stage == QLatin1String(JobStage::Start) ? i18n("Applications") : i18n(n.stage.toUtf8().constData());
const int columnPos = static_cast<int>(std::distance(columnStarts.begin(), std::find(columnStarts.begin(), columnStarts.end(), n.x)));
const qreal nextColumnX = columnPos + 1 < columnStarts.size() ? columnStarts.at(columnPos + 1) : width;
const qreal labelWidth = qMax<qreal>(24.0, nextColumnX - (n.x + n.width) - 8.0);
m_nodes.append(QVariantMap{
{u"stage"_s, n.stage},
{u"label"_s, label},
{u"x"_s, n.x},
{u"y"_s, n.y},
{u"width"_s, n.width},
{u"height"_s, n.height},
{u"value"_s, n.value},
{u"color"_s, JobStage::color(n.stage)},
{u"labelWidth"_s, labelWidth},
});
}
Q_EMIT changed();
}
+48
View File
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include "jobsdatabase.h"
#include <QObject>
#include <QQmlEngine>
#include <QVariantList>
/**
* Turns the recorded stage_history transitions into a laid-out Sankey
* diagram: a column per pipeline stage, nodes sized by how many
* applications passed through them, and ribbon-shaped links (as SVG path
* data, ready for QtQuick.Shapes' PathSvg) sized by transition counts.
*
* All geometry is computed here rather than in QML so the layout itself is
* unit-testable (see autotests/sankeylayouttest.cpp) and the QML side stays
* a plain renderer.
*/
class SankeyModel : public QObject
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(QVariantList nodes READ nodes NOTIFY changed)
Q_PROPERTY(QVariantList links READ links NOTIFY changed)
Q_PROPERTY(bool empty READ isEmpty NOTIFY changed)
public:
explicit SankeyModel(QObject *parent = nullptr);
QVariantList nodes() const;
QVariantList links() const;
bool isEmpty() const;
public Q_SLOTS:
/// Recomputes node/link geometry to fit within (width, height) logical
/// pixels. Call whenever the data or the available viewport changes.
void relayout(qreal width, qreal height);
Q_SIGNALS:
void changed();
private:
JobsDatabase m_db;
QVariantList m_nodes;
QVariantList m_links;
};
+100
View File
@@ -0,0 +1,100 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "statsmodel.h"
#include "jobstage.h"
using namespace Qt::Literals::StringLiterals;
StatsModel::StatsModel(QObject *parent)
: QObject(parent)
{
refresh();
}
void StatsModel::refresh()
{
m_jobs = m_db.allJobs();
Q_EMIT changed();
}
int StatsModel::totalApplications() const
{
return m_jobs.size();
}
int StatsModel::activeApplications() const
{
int count = 0;
for (const Job &job : m_jobs) {
if (!JobStage::isTerminal(job.stage)) {
++count;
}
}
return count;
}
int StatsModel::offerCount() const
{
int count = 0;
for (const Job &job : m_jobs) {
if (job.stage == QLatin1String("Offer") || job.stage == QLatin1String("Accepted")) {
++count;
}
}
return count;
}
int StatsModel::acceptedCount() const
{
int count = 0;
for (const Job &job : m_jobs) {
if (job.stage == QLatin1String("Accepted")) {
++count;
}
}
return count;
}
int StatsModel::rejectedCount() const
{
int count = 0;
for (const Job &job : m_jobs) {
if (job.stage == QLatin1String("Rejected")) {
++count;
}
}
return count;
}
double StatsModel::responseRate() const
{
if (m_jobs.isEmpty()) {
return 0.0;
}
int responded = 0;
for (const Job &job : m_jobs) {
if (job.stage != QLatin1String("Applied")) {
++responded;
}
}
return 100.0 * responded / m_jobs.size();
}
double StatsModel::offerRate() const
{
if (m_jobs.isEmpty()) {
return 0.0;
}
return 100.0 * offerCount() / m_jobs.size();
}
QVariantMap StatsModel::stageCounts() const
{
QVariantMap counts;
for (const QString &stage : JobStage::canonicalStages()) {
counts.insert(stage, 0);
}
for (const Job &job : m_jobs) {
counts[job.stage] = counts.value(job.stage, 0).toInt() + 1;
}
return counts;
}
+51
View File
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include "jobsdatabase.h"
#include <QObject>
#include <QQmlEngine>
#include <QVariantMap>
/**
* Summary counters for the dashboard, computed on demand from JobsDatabase.
* QML calls refresh() whenever the underlying jobs may have changed (the
* dashboard becoming visible is enough - this is cheap for the data sizes a
* personal tracker deals with).
*/
class StatsModel : public QObject
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(int totalApplications READ totalApplications NOTIFY changed)
Q_PROPERTY(int activeApplications READ activeApplications NOTIFY changed)
Q_PROPERTY(int offerCount READ offerCount NOTIFY changed)
Q_PROPERTY(int acceptedCount READ acceptedCount NOTIFY changed)
Q_PROPERTY(int rejectedCount READ rejectedCount NOTIFY changed)
Q_PROPERTY(double responseRate READ responseRate NOTIFY changed)
Q_PROPERTY(double offerRate READ offerRate NOTIFY changed)
Q_PROPERTY(QVariantMap stageCounts READ stageCounts NOTIFY changed)
public:
explicit StatsModel(QObject *parent = nullptr);
int totalApplications() const;
int activeApplications() const;
int offerCount() const;
int acceptedCount() const;
int rejectedCount() const;
double responseRate() const;
double offerRate() const;
QVariantMap stageCounts() const;
public Q_SLOTS:
void refresh();
Q_SIGNALS:
void changed();
private:
JobsDatabase m_db;
QList<Job> m_jobs;
};