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:
@@ -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})
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user