2 Commits
Author SHA1 Message Date
austinandClaude Fable 5 506b66cfdd v0.1.3: redesign the Sankey pipeline layout
Lint / REUSE compliance (push) Failing after 13s
Build and Test / Build and run tests (push) Failing after 44s
Lint / clang-format (push) Failing after 57s
Build and Publish Flatpak / Build Flatpak (push) Failing after 14s
Build and Publish Flatpak / Deploy hosted repo to Pages (push) Skipped
- Spread columns by rank among the stages actually present, so sparse
  pipelines fill the width instead of leaving dead stretches
- Top-align columns: the funnel's success path runs level along the top
  and drop-off ribbons only ever descend
- Give each ribbon its slot per node side (outgoing by target height,
  incoming by source height) so ribbons stop braiding over each other
- Move the last column's labels to its left so they always stay
  on-screen, with a theme halo to stay legible over ribbons
- Never hide small-node labels; elide with tooltip instead
- Guarantee columns and ribbons fit the viewport (clamp-aware scale,
  spill-proof ribbon thickness)
- Debounce resize relayouts and stop re-querying SQLite on resize
- Canonicalize stage names on write (CLI "rejected" -> "Rejected") and
  repair pre-existing rows on database open
- Take Sankey node width/padding from Kirigami units
- Add QuickShapesPrivate configure-time guard for QtQuick.Shapes
- Extend sankeylayouttest: viewport fit, ribbon containment, slot
  ordering, sparse-column spread, canonicalization, degenerate sizes
- README: build-dependency and generator fixes, document SettingsPage

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019FXfh48mASRsE1WEhy9jcM
2026-08-31 18:51:49 -05:00
austin 078343ff96 v0.1.2: Preferences page, About page, full icon set, redesigned edit form
- Rework the add/edit form onto a generic, catalog-driven field model
  (JobFieldCatalog + JobEditModel), replacing the old hand-written
  ApplicationEditDialog with ApplicationEditPage
- Add a Preferences page (reachable from the hamburger menu) with a
  KColorSchemeManager-backed color scheme switcher
- Add an About page reading from KAboutData
- Ship a full hicolor icon set (16-128px + scalable) via ecm_install_icons
  instead of a single flat SVG
- Restyle the edit form after KDE System Settings' Audio page:
  Kirigami.ListSectionHeader per category, flat separated rows, no card
  borders
- Explicitly declare Kirigami/KirigamiAddons/ColorScheme as CMake
  dependencies instead of relying on the QML import scanner alone
- Add KDEClangFormat/KDEGitCommitHooks, a CMakePresets.json, and a
  REUSE + clang-format lint CI workflow
- Reformat SPDX headers to include copyright, remove debug screenshot
  scaffolding
2026-07-03 16:25:42 -05:00
52 changed files with 1829 additions and 380 deletions
+16
View File
@@ -0,0 +1,16 @@
<!--
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: CC0-1.0
-->
## Reason for the change
<!-- What problem does this solve, or what does it add? -->
## Test plan
<!-- How did you verify this? Build/run steps, commands, etc. -->
## Screenshots
<!-- If this changes UI, include a before/after. -->
+2
View File
@@ -1,3 +1,5 @@
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
#
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
name: Build and Publish Flatpak name: Build and Publish Flatpak
+38
View File
@@ -0,0 +1,38 @@
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
#
# SPDX-License-Identifier: GPL-3.0-or-later
name: Lint
on:
pull_request: {}
push:
branches:
- main
jobs:
reuse:
name: REUSE compliance
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: REUSE Compliance Check
uses: fsfe/reuse-action@v3
clang-format:
name: clang-format
runs-on: ubuntu-latest
container:
image: ghcr.io/flathub-infra/flatpak-github-actions:kde-6.10
options: --privileged
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure (generates the KDE .clang-format style)
run: cmake -B build -G Ninja -DBUILD_TESTING=OFF
- name: Check formatting
run: |
find src autotests -name '*.h' -o -name '*.cpp' | \
xargs clang-format --dry-run --Werror
+2
View File
@@ -1,3 +1,5 @@
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
#
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
name: Build and Test name: Build and Test
+2
View File
@@ -8,3 +8,5 @@
# Editor / misc # Editor / misc
*.user *.user
*.autosave *.autosave
# Generated by ECM's KDEClangFormat module
/.clang-format
+25 -3
View File
@@ -1,9 +1,11 @@
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
#
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
# Kareer - a Kirigami/Qt job application tracker # Kareer - a Kirigami/Qt job application tracker
cmake_minimum_required(VERSION 3.16) cmake_minimum_required(VERSION 3.16)
project(kareer VERSION 0.1.1 LANGUAGES CXX) project(kareer VERSION 0.1.3 LANGUAGES CXX)
set(REQUIRED_QT_VERSION 6.6.0) set(REQUIRED_QT_VERSION 6.6.0)
set(REQUIRED_KF_VERSION 6.5.0) set(REQUIRED_KF_VERSION 6.5.0)
@@ -15,7 +17,10 @@ include(KDEInstallDirs)
include(KDECMakeSettings) include(KDECMakeSettings)
include(KDECompilerSettings NO_POLICY_SCOPE) include(KDECompilerSettings NO_POLICY_SCOPE)
include(ECMSetupVersion) include(ECMSetupVersion)
include(ECMInstallIcons)
include(FeatureSummary) include(FeatureSummary)
include(KDEClangFormat)
include(KDEGitCommitHooks)
set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -28,6 +33,9 @@ find_package(Qt6 ${REQUIRED_QT_VERSION} REQUIRED COMPONENTS
Widgets Widgets
Qml Qml
Quick Quick
# QtQuick.Shapes runtime QML module (SankeyDiagram.qml); configure-time
# guard only, nothing links against it.
QuickShapesPrivate
QuickControls2 QuickControls2
Sql Sql
Test Test
@@ -38,6 +46,19 @@ find_package(KF6 ${REQUIRED_KF_VERSION} REQUIRED COMPONENTS
CoreAddons CoreAddons
IconThemes IconThemes
Crash Crash
ColorScheme
)
find_package(KF6Kirigami ${REQUIRED_KF_VERSION} REQUIRED)
set_package_properties(KF6Kirigami PROPERTIES
TYPE REQUIRED
DESCRIPTION "KDE's next-gen UI framework"
)
find_package(KF6KirigamiAddons REQUIRED)
set_package_properties(KF6KirigamiAddons PROPERTIES
TYPE REQUIRED
DESCRIPTION "Add-on components for Kirigami"
) )
ecm_setup_version(${PROJECT_VERSION} ecm_setup_version(${PROJECT_VERSION}
@@ -45,6 +66,7 @@ ecm_setup_version(${PROJECT_VERSION}
VERSION_HEADER "${CMAKE_CURRENT_BINARY_DIR}/kareer-version.h" VERSION_HEADER "${CMAKE_CURRENT_BINARY_DIR}/kareer-version.h"
) )
add_subdirectory(icons)
add_subdirectory(src) add_subdirectory(src)
if(BUILD_TESTING) if(BUILD_TESTING)
@@ -55,11 +77,11 @@ install(PROGRAMS io.github.toservetheking.Kareer.desktop
DESTINATION ${KDE_INSTALL_APPDIR}) DESTINATION ${KDE_INSTALL_APPDIR})
install(FILES io.github.toservetheking.Kareer.metainfo.xml install(FILES io.github.toservetheking.Kareer.metainfo.xml
DESTINATION ${KDE_INSTALL_METAINFODIR}) DESTINATION ${KDE_INSTALL_METAINFODIR})
install(FILES icons/io.github.toservetheking.Kareer.svg
DESTINATION ${KDE_INSTALL_ICONDIR}/hicolor/scalable/apps)
install(FILES LICENSES/GPL-3.0-or-later.txt install(FILES LICENSES/GPL-3.0-or-later.txt
DESTINATION ${KDE_INSTALL_DATAROOTDIR}/licenses/${PROJECT_NAME}) DESTINATION ${KDE_INSTALL_DATAROOTDIR}/licenses/${PROJECT_NAME})
# Translations: add a po/ directory and re-enable ki18n_install(po) once present. # Translations: add a po/ directory and re-enable ki18n_install(po) once present.
kde_configure_git_pre_commit_hook(CHECKS CLANG-FORMAT)
feature_summary(WHAT ALL INCLUDE_QUIET_PACKAGES FATAL_ON_MISSING_REQUIRED_PACKAGES) feature_summary(WHAT ALL INCLUDE_QUIET_PACKAGES FATAL_ON_MISSING_REQUIRED_PACKAGES)
+20
View File
@@ -0,0 +1,20 @@
{
"version": 6,
"cmakeMinimumRequired": {
"major": 3,
"minor": 23,
"patch": 0
},
"configurePresets": [
{
"name": "ninja-dev",
"displayName": "Ninja (dev)",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug",
"BUILD_TESTING": "ON"
}
}
]
}
+2
View File
@@ -0,0 +1,2 @@
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: CC0-1.0
+14 -6
View File
@@ -100,19 +100,23 @@ Requires Qt 6, KDE Frameworks 6, Kirigami, Kirigami Addons and the
CMake toolchain. On Arch/CachyOS: CMake toolchain. On Arch/CachyOS:
```sh ```sh
sudo pacman -S --needed cmake extra-cmake-modules base-devel \ sudo pacman -S --needed cmake ninja extra-cmake-modules base-devel \
qt6-base qt6-declarative kirigami kirigami-addons \ qt6-base qt6-declarative vulkan-headers kirigami kirigami-addons \
ki18n kcoreaddons kiconthemes kcrash kitemmodels qqc2-desktop-style ki18n kcoreaddons kiconthemes kcrash kitemmodels \
kcolorscheme qqc2-desktop-style
``` ```
Then: Then:
```sh ```sh
cmake -B build -G Ninja cmake -B build
cmake --build build cmake --build build
./build/bin/kareer ./build/bin/kareer
``` ```
(If you have `ninja` installed, add `-G Ninja` to the configure step or
use the `ninja-dev` preset: `cmake --preset ninja-dev`.)
Run the tests with `ctest --test-dir build`. Run the tests with `ctest --test-dir build`.
## Architecture ## Architecture
@@ -128,8 +132,12 @@ Run the tests with `ctest --test-dir build`.
- `sankeymodel.{h,cpp}` - turns stage history into laid-out Sankey - `sankeymodel.{h,cpp}` - turns stage history into laid-out Sankey
geometry (node columns/stacking, ribbon SVG path data); QML only geometry (node columns/stacking, ribbon SVG path data); QML only
draws what this hands back. draws what this hands back.
- `jobfieldcatalog.{h,cpp}` - the static catalog of edit-form fields and categories.
- `jobeditmodel.{h,cpp}` - `QAbstractListModel`-backed edit-form state, built from the field catalog.
- `clicommands.{h,cpp}` - the `add`/`list`/`show`/`update`/`stage`/ - `clicommands.{h,cpp}` - the `add`/`list`/`show`/`update`/`stage`/
`delete`/`stats`/`stages` subcommands. `delete`/`stats`/`stages` subcommands.
- `appcolorscheme.{h,cpp}` - QML-facing wrapper around `KColorSchemeManager` for the Preferences page.
- `qml/` - Kirigami UI: `ApplicationsPage` (list + search), - `qml/` - Kirigami UI: `ApplicationsPage` (list + search),
`ApplicationEditDialog` (add/edit/delete form), `DashboardPage` `ApplicationEditPage` (add/edit/delete form), `DashboardPage`
(stat cards + pipeline), `SankeyDiagram` (the renderer). (stat cards + pipeline), `SankeyDiagram` (the renderer),
`SettingsPage` (the Preferences page).
+1
View File
@@ -7,6 +7,7 @@ SPDX-PackageDownloadLocation = "https://github.com/toservetheking/Kareer"
path = [ path = [
"io.github.toservetheking.Kareer.desktop", "io.github.toservetheking.Kareer.desktop",
"keys/*.asc", "keys/*.asc",
"icons/*.png",
] ]
SPDX-License-Identifier = "GPL-3.0-or-later" SPDX-License-Identifier = "GPL-3.0-or-later"
SPDX-FileCopyrightText = "ToServeTheKing <[email protected]>" SPDX-FileCopyrightText = "ToServeTheKing <[email protected]>"
+2
View File
@@ -1,3 +1,5 @@
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
#
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
add_executable(jobsdatabasetest add_executable(jobsdatabasetest
+6 -1
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "job.h" #include "job.h"
#include "jobsdatabase.h" #include "jobsdatabase.h"
+271 -17
View File
@@ -1,8 +1,15 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "job.h" #include "job.h"
#include "jobsdatabase.h" #include "jobsdatabase.h"
#include "sankeymodel.h" #include "sankeymodel.h"
#include <QSqlDatabase>
#include <QSqlQuery>
#include <QTemporaryDir> #include <QTemporaryDir>
#include <QtTest> #include <QtTest>
#include <memory> #include <memory>
@@ -13,6 +20,13 @@ class SankeyLayoutTest : public QObject
{ {
Q_OBJECT Q_OBJECT
// Defaults of SankeyModel::reload()/relayout(), which every test uses.
static constexpr qreal nodeWidth = 16.0;
static constexpr qreal rowPadding = 10.0;
// The label margin/floor baked into the layout (sankeymodel.cpp).
static constexpr qreal labelMargin = 8.0;
static constexpr qreal eps = 0.01;
private Q_SLOTS: private Q_SLOTS:
void init() void init()
{ {
@@ -25,23 +39,13 @@ private Q_SLOTS:
{ {
JobsDatabase db; JobsDatabase db;
auto makeJob = [&](const QString &stage) { makeJob(db, QStringLiteral("Applied"));
Job job; makeJob(db, QStringLiteral("Applied"));
job.company = QStringLiteral("Co"); makeJob(db, QStringLiteral("Screening"));
job.title = QStringLiteral("Title"); makeJob(db, QStringLiteral("Rejected"));
QVERIFY(db.addJob(job));
if (stage != QStringLiteral("Applied")) {
QVERIFY(db.setStage(job.id, stage));
}
};
makeJob(QStringLiteral("Applied"));
makeJob(QStringLiteral("Applied"));
makeJob(QStringLiteral("Screening"));
makeJob(QStringLiteral("Rejected"));
SankeyModel model; SankeyModel model;
model.relayout(600, 400); model.reload(600, 400);
QVERIFY(!model.isEmpty()); QVERIFY(!model.isEmpty());
@@ -80,13 +84,263 @@ private Q_SLOTS:
QVERIFY(db.isOpen()); QVERIFY(db.isOpen());
SankeyModel model; SankeyModel model;
model.relayout(400, 300); model.reload(400, 300);
QVERIFY(model.isEmpty()); QVERIFY(model.isEmpty());
QVERIFY(model.nodes().isEmpty()); QVERIFY(model.nodes().isEmpty());
QVERIFY(model.links().isEmpty()); QVERIFY(model.links().isEmpty());
} }
void geometryFitsViewport()
{
JobsDatabase db;
seedDensePipeline(db);
SankeyModel model;
for (const QSizeF size : {QSizeF(600, 300), QSizeF(600, 40)}) {
model.reload(size.width(), size.height());
QVERIFY(!model.isEmpty());
QHash<qreal, qreal> columnHeights;
QHash<qreal, int> columnCounts;
for (const QVariant &v : model.nodes()) {
const QVariantMap m = v.toMap();
const qreal x = m.value(QStringLiteral("x")).toReal();
const qreal y = m.value(QStringLiteral("y")).toReal();
const qreal w = m.value(QStringLiteral("width")).toReal();
const qreal h = m.value(QStringLiteral("height")).toReal();
QVERIFY(x >= -eps);
QVERIFY(x + w <= size.width() + eps);
QVERIFY(y >= -eps);
QVERIFY(y + h <= size.height() + eps);
columnHeights[x] += h;
columnCounts[x] += 1;
// The label rect must lie inside the viewport too.
const qreal labelWidth = m.value(QStringLiteral("labelWidth")).toReal();
if (m.value(QStringLiteral("labelOnRight")).toBool()) {
QVERIFY(x + w + labelMargin + labelWidth <= size.width() + eps);
} else {
QVERIFY(x - labelMargin - labelWidth >= -eps);
}
}
for (auto it = columnHeights.constBegin(); it != columnHeights.constEnd(); ++it) {
const qreal gaps = rowPadding * (columnCounts.value(it.key()) - 1);
QVERIFY(it.value() + gaps <= size.height() + eps);
}
}
}
void ribbonsStayInsideNodes()
{
JobsDatabase db;
seedDensePipeline(db);
SankeyModel model;
model.reload(600, 300);
QVERIFY(!model.isEmpty());
QHash<QString, QVariantMap> nodeByStage;
for (const QVariant &v : model.nodes()) {
const QVariantMap m = v.toMap();
nodeByStage.insert(m.value(QStringLiteral("stage")).toString(), m);
}
QHash<QString, qreal> outboundTotal;
QHash<QString, qreal> inboundTotal;
for (const QVariant &v : model.links()) {
const QVariantMap m = v.toMap();
const qreal thickness = m.value(QStringLiteral("thickness")).toReal();
QVERIFY(thickness > 0);
const QVariantMap fromNode = nodeByStage.value(m.value(QStringLiteral("fromStage")).toString());
const QVariantMap toNode = nodeByStage.value(m.value(QStringLiteral("toStage")).toString());
const qreal sourceY = m.value(QStringLiteral("sourceY")).toReal();
const qreal targetY = m.value(QStringLiteral("targetY")).toReal();
QVERIFY(sourceY >= fromNode.value(QStringLiteral("y")).toReal() - eps);
QVERIFY(sourceY + thickness <= fromNode.value(QStringLiteral("y")).toReal() + fromNode.value(QStringLiteral("height")).toReal() + eps);
QVERIFY(targetY >= toNode.value(QStringLiteral("y")).toReal() - eps);
QVERIFY(targetY + thickness <= toNode.value(QStringLiteral("y")).toReal() + toNode.value(QStringLiteral("height")).toReal() + eps);
outboundTotal[m.value(QStringLiteral("fromStage")).toString()] += thickness;
inboundTotal[m.value(QStringLiteral("toStage")).toString()] += thickness;
}
for (auto it = outboundTotal.constBegin(); it != outboundTotal.constEnd(); ++it) {
QVERIFY(it.value() <= nodeByStage.value(it.key()).value(QStringLiteral("height")).toReal() + eps);
}
for (auto it = inboundTotal.constBegin(); it != inboundTotal.constEnd(); ++it) {
QVERIFY(it.value() <= nodeByStage.value(it.key()).value(QStringLiteral("height")).toReal() + eps);
}
}
void ribbonSlotsFollowEndpointHeights()
{
JobsDatabase db;
seedDensePipeline(db);
SankeyModel model;
model.reload(600, 300);
QVERIFY(!model.isEmpty());
QHash<QString, QVariantMap> nodeByStage;
for (const QVariant &v : model.nodes()) {
const QVariantMap m = v.toMap();
nodeByStage.insert(m.value(QStringLiteral("stage")).toString(), m);
}
const auto center = [&](const QString &stage) {
const QVariantMap n = nodeByStage.value(stage);
return n.value(QStringLiteral("y")).toReal() + n.value(QStringLiteral("height")).toReal() / 2.0;
};
// Outgoing ribbons must stack top-to-bottom in order of their
// target's height, incoming ones by their source's height, or the
// ribbons twist over each other right at the node.
QHash<QString, QList<QPair<qreal, qreal>>> outgoing; // sourceY -> target center
QHash<QString, QList<QPair<qreal, qreal>>> incoming; // targetY -> source center
for (const QVariant &v : model.links()) {
const QVariantMap m = v.toMap();
outgoing[m.value(QStringLiteral("fromStage")).toString()].append(
{m.value(QStringLiteral("sourceY")).toReal(), center(m.value(QStringLiteral("toStage")).toString())});
incoming[m.value(QStringLiteral("toStage")).toString()].append(
{m.value(QStringLiteral("targetY")).toReal(), center(m.value(QStringLiteral("fromStage")).toString())});
}
const auto verifySorted = [](QList<QPair<qreal, qreal>> slots) {
std::sort(slots.begin(), slots.end());
for (int i = 1; i < slots.size(); ++i) {
QVERIFY(slots.at(i).second >= slots.at(i - 1).second - 0.01);
}
};
for (const auto &slots : std::as_const(outgoing)) {
verifySorted(slots);
}
for (const auto &slots : std::as_const(incoming)) {
verifySorted(slots);
}
}
void sparseColumnsFillWidth()
{
JobsDatabase db;
makeJob(db, QStringLiteral("Applied"));
makeJob(db, QStringLiteral("Applied"));
makeJob(db, QStringLiteral("Rejected"));
SankeyModel model;
model.reload(600, 300);
// Only Start, Applied and Rejected are present: their columns should
// spread evenly over the full width, not sit at canonical positions.
QList<qreal> xs;
for (const QVariant &v : model.nodes()) {
const qreal x = v.toMap().value(QStringLiteral("x")).toReal();
if (!xs.contains(x)) {
xs.append(x);
}
}
std::sort(xs.begin(), xs.end());
QCOMPARE(xs.size(), 3);
QVERIFY(qAbs(xs.at(0)) < eps);
QVERIFY(qAbs(xs.at(1) - (600 - nodeWidth) / 2.0) < eps);
QVERIFY(qAbs(xs.at(2) - (600 - nodeWidth)) < eps);
}
void caseVariantStageIsCanonicalized()
{
JobsDatabase db;
Job job;
job.company = QStringLiteral("Co");
job.title = QStringLiteral("Title");
QVERIFY(db.addJob(job));
QVERIFY(db.setStage(job.id, QStringLiteral("rejected")));
QCOMPARE(db.jobById(job.id)->stage, QStringLiteral("Rejected"));
SankeyModel model;
model.reload(600, 300);
QStringList stages;
for (const QVariant &v : model.nodes()) {
stages.append(v.toMap().value(QStringLiteral("stage")).toString());
}
QVERIFY(stages.contains(QStringLiteral("Rejected")));
QVERIFY(!stages.contains(QStringLiteral("rejected")));
// Pre-canonicalization rows already in the database are repaired the
// next time it is opened (migrate() normalization).
{
QSqlDatabase raw = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), QStringLiteral("fixup"));
raw.setDatabaseName(m_dir->path() + QStringLiteral("/test.sqlite"));
QVERIFY(raw.open());
QSqlQuery q(raw);
QVERIFY(q.exec(QStringLiteral("UPDATE jobs SET stage = 'ghosted'")));
QVERIFY(q.exec(QStringLiteral("UPDATE stage_history SET to_stage = 'ghosted' WHERE to_stage = 'Rejected'")));
raw.close();
}
QSqlDatabase::removeDatabase(QStringLiteral("fixup"));
JobsDatabase reopened;
QVERIFY(reopened.isOpen());
QCOMPARE(reopened.jobById(job.id)->stage, QStringLiteral("Ghosted"));
const auto transitions = reopened.stageTransitions();
for (const StageTransition &t : transitions) {
QVERIFY(t.toStage != QStringLiteral("ghosted"));
}
}
void degenerateSizesDoNotCrash()
{
JobsDatabase db;
seedDensePipeline(db);
SankeyModel model;
model.reload(0, 0);
QVERIFY(model.isEmpty());
model.relayout(-5, 100);
QVERIFY(model.isEmpty());
model.relayout(5, 5);
model.relayout(2000, 2);
model.relayout(600, 300);
QVERIFY(!model.isEmpty());
}
private: private:
static void makeJob(JobsDatabase &db, const QString &stage)
{
Job job;
job.company = QStringLiteral("Co");
job.title = QStringLiteral("Title");
QVERIFY(db.addJob(job));
if (stage != QStringLiteral("Applied")) {
QVERIFY(db.setStage(job.id, stage));
}
}
static void walkJob(JobsDatabase &db, const QStringList &stages)
{
Job job;
job.company = QStringLiteral("Co");
job.title = QStringLiteral("Title");
QVERIFY(db.addJob(job));
for (const QString &stage : stages) {
QVERIFY(db.setStage(job.id, stage));
}
}
/// Populates every column, including all four terminal outcomes sharing
/// the last one.
static void seedDensePipeline(JobsDatabase &db)
{
walkJob(db, {QStringLiteral("Screening"), QStringLiteral("Interview"), QStringLiteral("Onsite"), QStringLiteral("Offer"), QStringLiteral("Accepted")});
walkJob(db, {QStringLiteral("Screening"), QStringLiteral("Rejected")});
walkJob(db, {QStringLiteral("Ghosted")});
walkJob(db, {QStringLiteral("Screening"), QStringLiteral("Interview"), QStringLiteral("Rejected")});
walkJob(db, {QStringLiteral("Withdrawn")});
walkJob(db, {QStringLiteral("Screening"), QStringLiteral("Interview"), QStringLiteral("Onsite"), QStringLiteral("Rejected")});
walkJob(db, {});
walkJob(db, {});
walkJob(db, {});
walkJob(db, {});
}
std::unique_ptr<QTemporaryDir> m_dir; std::unique_ptr<QTemporaryDir> m_dir;
}; };
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 825 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

+16
View File
@@ -0,0 +1,16 @@
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
#
# SPDX-License-Identifier: GPL-3.0-or-later
ecm_install_icons(ICONS
sc-apps-kareer.svg
128-apps-kareer.png
64-apps-kareer.png
48-apps-kareer.png
44-apps-kareer.png
32-apps-kareer.png
22-apps-kareer.png
16-apps-kareer.png
DESTINATION ${KDE_INSTALL_ICONDIR}
THEME hicolor
)
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]> -->
<!-- SPDX-License-Identifier: GPL-3.0-or-later --> <!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg"> <svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<defs> <defs>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -64,6 +64,27 @@
<content_rating type="oars-1.1"/> <content_rating type="oars-1.1"/>
<releases> <releases>
<release version="0.1.3" date="2026-08-31">
<description>
<ul>
<li>Redesign the Sankey pipeline layout: top-aligned funnel, columns spread across the full width, labels always visible</li>
<li>Order ribbons at each stage to stop them braiding over each other</li>
<li>Canonicalize stage names written via the CLI (e.g. "rejected" now maps to "Rejected") and repair existing databases</li>
<li>Debounce window-resize relayouts and stop re-reading the database on resize</li>
</ul>
</description>
</release>
<release version="0.1.2" date="2026-07-03">
<description>
<ul>
<li>Rework the add/edit form onto a generic, catalog-driven field model</li>
<li>Add a Preferences page with a color-scheme switcher</li>
<li>Add an About page</li>
<li>Ship a full hicolor icon set instead of a single scalable SVG</li>
<li>Match KDE System Settings' Audio page layout for the edit form</li>
</ul>
</description>
</release>
<release version="0.1.1" date="2026-07-03"> <release version="0.1.1" date="2026-07-03">
<description> <description>
<ul> <ul>
+10 -8
View File
@@ -1,3 +1,5 @@
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
#
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
id: io.github.toservetheking.Kareer id: io.github.toservetheking.Kareer
runtime: org.kde.Platform runtime: org.kde.Platform
@@ -27,12 +29,12 @@ modules:
- -DCMAKE_BUILD_TYPE=Release - -DCMAKE_BUILD_TYPE=Release
run-tests: true run-tests: true
sources: sources:
# For Flathub / release builds, pin to a tag AND commit: # For local testing before the repo is pushed:
- type: git - type: dir
url: https://github.com/toservetheking/Kareer.git path: .
tag: v0.1.1
# commit: <fill in the exact commit SHA the tag points at>
# #
# For local testing before the repo is pushed, replace the source above with: # For Flathub / release builds, pin to a tag AND commit instead:
# - type: dir # - type: git
# path: . # url: https://github.com/toservetheking/Kareer.git
# tag: v0.1.1
# commit: <fill in the exact commit SHA the tag points at>
+11 -1
View File
@@ -1,9 +1,12 @@
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
#
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
add_executable(kareer add_executable(kareer
main.cpp main.cpp
jobstage.cpp jobstage.cpp
jobsdatabase.cpp jobsdatabase.cpp
jobfieldcatalog.cpp
clicommands.cpp clicommands.cpp
) )
@@ -14,9 +17,10 @@ qt_add_qml_module(kareer
QML_FILES QML_FILES
qml/Main.qml qml/Main.qml
qml/ApplicationsPage.qml qml/ApplicationsPage.qml
qml/ApplicationEditDialog.qml qml/ApplicationEditPage.qml
qml/DashboardPage.qml qml/DashboardPage.qml
qml/SankeyDiagram.qml qml/SankeyDiagram.qml
qml/SettingsPage.qml
SOURCES SOURCES
jobsmodel.cpp jobsmodel.cpp
jobsmodel.h jobsmodel.h
@@ -24,6 +28,10 @@ qt_add_qml_module(kareer
statsmodel.h statsmodel.h
sankeymodel.cpp sankeymodel.cpp
sankeymodel.h sankeymodel.h
jobeditmodel.cpp
jobeditmodel.h
appcolorscheme.cpp
appcolorscheme.h
) )
target_include_directories(kareer PRIVATE ${CMAKE_BINARY_DIR}) target_include_directories(kareer PRIVATE ${CMAKE_BINARY_DIR})
@@ -41,6 +49,8 @@ target_link_libraries(kareer PRIVATE
KF6::CoreAddons KF6::CoreAddons
KF6::IconThemes KF6::IconThemes
KF6::Crash KF6::Crash
KF6::ColorScheme
KF6::Kirigami
) )
install(TARGETS kareer ${KDE_INSTALL_TARGETS_DEFAULT_ARGS}) install(TARGETS kareer ${KDE_INSTALL_TARGETS_DEFAULT_ARGS})
+34
View File
@@ -0,0 +1,34 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "appcolorscheme.h"
#include <KColorSchemeManager>
AppColorScheme::AppColorScheme(QObject *parent)
: QObject(parent)
, mSchemes(KColorSchemeManager::instance())
{
}
QAbstractItemModel *AppColorScheme::colorSchemesModel()
{
return mSchemes->model();
}
QString AppColorScheme::activeColorSchemeName() const
{
return mSchemes->activeSchemeName();
}
void AppColorScheme::setActiveColorSchemeName(const QString &name)
{
if (name == activeColorSchemeName()) {
return;
}
mSchemes->activateScheme(mSchemes->indexForScheme(name));
Q_EMIT activeColorSchemeNameChanged();
}
+40
View File
@@ -0,0 +1,40 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once
#include <QAbstractItemModel>
#include <QObject>
#include <QQmlEngine>
class KColorSchemeManager;
/**
* Thin QML-facing wrapper around KColorSchemeManager: exposes the list of
* installed color schemes and the currently active one. KColorSchemeManager
* autosaves the active scheme itself, so there is nothing else to persist.
*/
class AppColorScheme : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
Q_PROPERTY(QAbstractItemModel *colorSchemesModel READ colorSchemesModel CONSTANT)
Q_PROPERTY(QString activeColorSchemeName READ activeColorSchemeName WRITE setActiveColorSchemeName NOTIFY activeColorSchemeNameChanged)
public:
explicit AppColorScheme(QObject *parent = nullptr);
QAbstractItemModel *colorSchemesModel();
QString activeColorSchemeName() const;
void setActiveColorSchemeName(const QString &name);
Q_SIGNALS:
void activeColorSchemeNameChanged();
private:
KColorSchemeManager *mSchemes;
};
+7 -2
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "clicommands.h" #include "clicommands.h"
#include "job.h" #include "job.h"
@@ -438,7 +443,7 @@ int runStage(const QString &program, const QStringList &args)
if (parser.isSet(u"json"_s)) { if (parser.isSet(u"json"_s)) {
printJson(jobToJson(*job)); printJson(jobToJson(*job));
} else { } else {
QTextStream(stdout) << u"Application #%1 moved to %2"_s.arg(id).arg(stage) << Qt::endl; QTextStream(stdout) << u"Application #%1 moved to %2"_s.arg(id).arg(job->stage) << Qt::endl;
} }
return 0; return 0;
} }
+6 -1
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once #pragma once
#include <QString> #include <QString>
+6 -1
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once #pragma once
#include <QDate> #include <QDate>
+204
View File
@@ -0,0 +1,204 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "jobeditmodel.h"
#include <QDate>
using namespace Qt::Literals::StringLiterals;
using JobFieldCatalog::ComboRow;
using JobFieldCatalog::DateRow;
using JobFieldCatalog::Field;
using JobFieldCatalog::SpinBoxRow;
using JobFieldCatalog::TextAreaRow;
using JobFieldCatalog::TextRow;
namespace
{
/// The three salary fields share the "0 shown in the UI means unset, -1 stored" convention.
bool isSalaryField(const QString &id)
{
return id == QLatin1String("salaryMin") || id == QLatin1String("salaryMax") || id == QLatin1String("salaryExpectation");
}
}
JobEditModel::JobEditModel(QObject *parent)
: QAbstractListModel(parent)
, m_fields(JobFieldCatalog::fields())
{
resetValues();
}
int JobEditModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) {
return 0;
}
return m_fields.size();
}
QVariant JobEditModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_fields.size()) {
return {};
}
const Field &field = m_fields.at(index.row());
switch (role) {
case FieldIdRole:
return field.id;
case CategoryIdRole:
return field.categoryId;
case LabelRole:
return field.label;
case RowTypeRole:
return static_cast<int>(field.rowType);
case ValueRole:
return m_values.value(field.id);
case ComboOptionsRole:
return field.comboOptions;
case PlaceholderRole:
return field.placeholder;
case SpinMinRole:
return field.spinMin;
case SpinMaxRole:
return field.spinMax;
default:
return {};
}
}
QHash<int, QByteArray> JobEditModel::roleNames() const
{
return {
{FieldIdRole, "fieldId"},
{CategoryIdRole, "categoryId"},
{LabelRole, "label"},
{RowTypeRole, "rowType"},
{ValueRole, "value"},
{ComboOptionsRole, "comboOptions"},
{PlaceholderRole, "placeholder"},
{SpinMinRole, "spinMin"},
{SpinMaxRole, "spinMax"},
};
}
JobsModel *JobEditModel::jobsModel() const
{
return m_jobsModel;
}
void JobEditModel::setJobsModel(JobsModel *model)
{
if (m_jobsModel == model) {
return;
}
m_jobsModel = model;
Q_EMIT jobsModelChanged();
}
int JobEditModel::editingJobId() const
{
return m_editingJobId;
}
void JobEditModel::setEditingJobId(int id)
{
if (m_editingJobId == id) {
return;
}
m_editingJobId = id;
resetValues();
Q_EMIT editingJobIdChanged();
}
void JobEditModel::resetValues()
{
m_values.clear();
if (m_editingJobId < 0 || !m_jobsModel) {
m_values[u"currency"_s] = u"USD"_s;
m_values[u"stage"_s] = u"Applied"_s;
m_values[u"dateApplied"_s] = QDate::currentDate();
m_values[u"remoteType"_s] = u"Unspecified"_s;
m_values[u"salaryMin"_s] = 0;
m_values[u"salaryMax"_s] = 0;
m_values[u"salaryExpectation"_s] = 0;
} else {
const QVariantMap data = m_jobsModel->jobData(m_editingJobId);
for (const Field &field : m_fields) {
QVariant value = data.value(field.id);
if (field.id == u"remoteType"_s && value.toString().isEmpty()) {
value = u"Unspecified"_s;
}
if (isSalaryField(field.id) && value.toInt() < 0) {
value = 0;
}
m_values[field.id] = value;
}
}
if (rowCount() > 0) {
Q_EMIT dataChanged(index(0), index(rowCount() - 1));
}
}
QVariantList JobEditModel::categories() const
{
QVariantList result;
for (const JobFieldCatalog::Category &category : JobFieldCatalog::categories()) {
result.append(QVariantMap{{u"id"_s, category.id}, {u"title"_s, category.title}});
}
return result;
}
QString JobEditModel::lastError() const
{
return m_lastError;
}
void JobEditModel::setValue(int row, const QVariant &value)
{
if (row < 0 || row >= m_fields.size()) {
return;
}
m_values[m_fields.at(row).id] = value;
Q_EMIT dataChanged(index(row), index(row), {ValueRole});
}
bool JobEditModel::save()
{
if (!m_jobsModel) {
return false;
}
QVariantMap fields;
for (auto it = m_values.constBegin(); it != m_values.constEnd(); ++it) {
fields.insert(it.key(), it.value());
}
if (fields.value(u"remoteType"_s).toString() == u"Unspecified"_s) {
fields[u"remoteType"_s] = QString();
}
for (const QString &id : {u"salaryMin"_s, u"salaryMax"_s, u"salaryExpectation"_s}) {
if (fields.value(id).toInt() <= 0) {
fields[id] = -1;
}
}
const bool ok = m_editingJobId < 0 ? m_jobsModel->addJob(fields) : m_jobsModel->updateJob(m_editingJobId, fields);
if (!ok) {
m_lastError = m_jobsModel->lastError();
Q_EMIT lastErrorChanged();
}
return ok;
}
bool JobEditModel::deleteJob()
{
if (!m_jobsModel || m_editingJobId < 0) {
return false;
}
return m_jobsModel->removeJob(m_editingJobId);
}
+83
View File
@@ -0,0 +1,83 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once
#include "jobfieldcatalog.h"
#include "jobsmodel.h"
#include <QAbstractListModel>
#include <QHash>
#include <QQmlEngine>
#include <QVariant>
/**
* Drives the add/edit form the way FlatKontrol's PermissionsController drives
* PermissionsPage: a generic row model (one row per JobFieldCatalog::Field)
* that QML renders via Repeater + DelegateChooser on rowType, instead of
* each field being hand-written in QML.
*/
class JobEditModel : public QAbstractListModel
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(JobsModel *jobsModel READ jobsModel WRITE setJobsModel NOTIFY jobsModelChanged)
Q_PROPERTY(int editingJobId READ editingJobId WRITE setEditingJobId NOTIFY editingJobIdChanged)
Q_PROPERTY(QVariantList categories READ categories CONSTANT)
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
public:
enum Roles {
FieldIdRole = Qt::UserRole + 1,
CategoryIdRole,
LabelRole,
RowTypeRole,
ValueRole,
ComboOptionsRole,
PlaceholderRole,
SpinMinRole,
SpinMaxRole,
};
Q_ENUM(Roles)
explicit JobEditModel(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;
JobsModel *jobsModel() const;
void setJobsModel(JobsModel *model);
int editingJobId() const;
void setEditingJobId(int id);
QVariantList categories() const;
QString lastError() const;
/// Updates the value for the field at this row (called from the QML delegate).
Q_INVOKABLE void setValue(int row, const QVariant &value);
/// Persists the current values via jobsModel; true on success.
Q_INVOKABLE bool save();
Q_INVOKABLE bool deleteJob();
Q_SIGNALS:
void jobsModelChanged();
void editingJobIdChanged();
void lastErrorChanged();
private:
void resetValues();
JobsModel *m_jobsModel = nullptr;
int m_editingJobId = -1;
QHash<QString, QVariant> m_values;
QString m_lastError;
QList<JobFieldCatalog::Field> m_fields;
};
+48
View File
@@ -0,0 +1,48 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "jobfieldcatalog.h"
#include "jobstage.h"
using namespace Qt::Literals::StringLiterals;
namespace JobFieldCatalog
{
QList<Category> categories()
{
return {
{u"company"_s, QStringLiteral("Company")},
{u"pipeline"_s, QStringLiteral("Pipeline")},
{u"salary"_s, QStringLiteral("Salary")},
{u"details"_s, QStringLiteral("Additional Details")},
};
}
QList<Field> fields()
{
return {
{u"company"_s, u"company"_s, QStringLiteral("Company:"), TextRow, {}, {}, 0, 0},
{u"title"_s, u"company"_s, QStringLiteral("Job Title:"), TextRow, {}, {}, 0, 0},
{u"location"_s, u"company"_s, QStringLiteral("Location:"), TextRow, {}, {}, 0, 0},
{u"remoteType"_s, u"company"_s, QStringLiteral("Remote Type:"), ComboRow, {QStringLiteral("Unspecified"), QStringLiteral("Onsite"), QStringLiteral("Hybrid"), QStringLiteral("Remote")}, {}, 0, 0},
{u"stage"_s, u"pipeline"_s, QStringLiteral("Stage:"), ComboRow, JobStage::canonicalStages(), {}, 0, 0},
{u"dateApplied"_s, u"pipeline"_s, QStringLiteral("Date Applied:"), DateRow, {}, {}, 0, 0},
{u"salaryMin"_s, u"salary"_s, QStringLiteral("Range Minimum:"), SpinBoxRow, {}, {}, 0, 5000000},
{u"salaryMax"_s, u"salary"_s, QStringLiteral("Range Maximum:"), SpinBoxRow, {}, {}, 0, 5000000},
{u"salaryExpectation"_s, u"salary"_s, QStringLiteral("Your Expectation:"), SpinBoxRow, {}, {}, 0, 5000000},
{u"currency"_s, u"salary"_s, QStringLiteral("Currency:"), TextRow, {}, {}, 0, 0},
{u"source"_s, u"details"_s, QStringLiteral("Source:"), TextRow, {}, QStringLiteral("Referral, LinkedIn, company site..."), 0, 0},
{u"url"_s, u"details"_s, QStringLiteral("Job Posting URL:"), TextRow, {}, {}, 0, 0},
{u"contact"_s, u"details"_s, QStringLiteral("Contact:"), TextRow, {}, {}, 0, 0},
{u"notes"_s, u"details"_s, QStringLiteral("Notes:"), TextAreaRow, {}, {}, 0, 0},
};
}
}
+47
View File
@@ -0,0 +1,47 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once
#include <QString>
#include <QStringList>
#include <QList>
/**
* The static structure of the job edit form: which categories exist, and
* which fields belong to each, in display order. Mirrors FlatKontrol's
* PermissionCatalog - JobEditModel supplies the per-job values, this
* supplies the shape.
*/
namespace JobFieldCatalog
{
enum RowType {
TextRow = 0,
ComboRow,
SpinBoxRow,
DateRow,
TextAreaRow,
};
struct Category {
QString id;
QString title;
};
struct Field {
QString id; ///< Matches a Job/JobsModel field key (see JobsModel::mapFromJob).
QString categoryId;
QString label;
RowType rowType;
QStringList comboOptions; ///< Only meaningful for ComboRow.
QString placeholder; ///< Only meaningful for TextRow.
int spinMin = 0; ///< Only meaningful for SpinBoxRow.
int spinMax = 0;
};
QList<Category> categories();
QList<Field> fields();
}
+31 -4
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "jobsdatabase.h" #include "jobsdatabase.h"
#include "jobstage.h" #include "jobstage.h"
@@ -127,6 +132,26 @@ bool JobsDatabase::migrate()
return false; return false;
} }
// Rows written before stages were canonicalized on write (e.g.
// "rejected" via the CLI) would otherwise show up as separate Sankey
// nodes with default column/color. lower() is ASCII-only, which is fine:
// the stage vocabulary is ASCII.
for (const QString &stage : JobStage::canonicalStages()) {
for (const QString &statement : {
u"UPDATE jobs SET stage = :s WHERE stage != :s AND lower(stage) = lower(:s)"_s,
u"UPDATE stage_history SET to_stage = :s WHERE to_stage != :s AND lower(to_stage) = lower(:s)"_s,
u"UPDATE stage_history SET from_stage = :s WHERE from_stage != :s AND lower(from_stage) = lower(:s)"_s,
}) {
QSqlQuery normalize(db);
normalize.prepare(statement);
normalize.bindValue(u":s"_s, stage);
if (!normalize.exec()) {
m_lastError = normalize.lastError().text();
return false;
}
}
}
return true; return true;
} }
@@ -197,6 +222,7 @@ bool JobsDatabase::addJob(Job &job)
m_lastError = u"Unknown stage '%1'"_s.arg(job.stage); m_lastError = u"Unknown stage '%1'"_s.arg(job.stage);
return false; return false;
} }
job.stage = JobStage::canonical(job.stage);
QSqlDatabase db = QSqlDatabase::database(m_connectionName); QSqlDatabase db = QSqlDatabase::database(m_connectionName);
const QDateTime now = QDateTime::currentDateTimeUtc(); const QDateTime now = QDateTime::currentDateTimeUtc();
@@ -294,13 +320,14 @@ bool JobsDatabase::setStage(int id, const QString &newStage)
m_lastError = u"Unknown stage '%1'"_s.arg(newStage); m_lastError = u"Unknown stage '%1'"_s.arg(newStage);
return false; return false;
} }
const QString stage = JobStage::canonical(newStage);
const auto current = jobById(id); const auto current = jobById(id);
if (!current) { if (!current) {
m_lastError = u"No job with id %1"_s.arg(id); m_lastError = u"No job with id %1"_s.arg(id);
return false; return false;
} }
if (current->stage.compare(newStage, Qt::CaseInsensitive) == 0) { if (current->stage.compare(stage, Qt::CaseInsensitive) == 0) {
return true; return true;
} }
@@ -309,7 +336,7 @@ bool JobsDatabase::setStage(int id, const QString &newStage)
QSqlQuery query(db); QSqlQuery query(db);
query.prepare(u"UPDATE jobs SET stage = :stage, updated_at = :updated_at WHERE id = :id"_s); 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":stage"_s, stage);
query.bindValue(u":updated_at"_s, now.toString(Qt::ISODate)); query.bindValue(u":updated_at"_s, now.toString(Qt::ISODate));
query.bindValue(u":id"_s, id); query.bindValue(u":id"_s, id);
if (!query.exec()) { if (!query.exec()) {
@@ -321,7 +348,7 @@ bool JobsDatabase::setStage(int id, const QString &newStage)
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.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":job_id"_s, id);
history.bindValue(u":from_stage"_s, current->stage); history.bindValue(u":from_stage"_s, current->stage);
history.bindValue(u":to_stage"_s, newStage); history.bindValue(u":to_stage"_s, stage);
history.bindValue(u":changed_at"_s, now.toString(Qt::ISODate)); history.bindValue(u":changed_at"_s, now.toString(Qt::ISODate));
if (!history.exec()) { if (!history.exec()) {
m_lastError = history.lastError().text(); m_lastError = history.lastError().text();
+6 -1
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once #pragma once
#include "job.h" #include "job.h"
+6 -1
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "jobsmodel.h" #include "jobsmodel.h"
#include "jobstage.h" #include "jobstage.h"
+6 -1
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once #pragma once
#include "job.h" #include "job.h"
+19 -1
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "jobstage.h" #include "jobstage.h"
#include <QHash> #include <QHash>
@@ -27,6 +32,19 @@ bool isValid(const QString &stage)
return canonicalStages().contains(stage, Qt::CaseInsensitive); return canonicalStages().contains(stage, Qt::CaseInsensitive);
} }
QString canonical(const QString &stage)
{
if (stage.compare(QLatin1String(Start), Qt::CaseInsensitive) == 0) {
return QString::fromLatin1(Start);
}
for (const QString &candidate : canonicalStages()) {
if (stage.compare(candidate, Qt::CaseInsensitive) == 0) {
return candidate;
}
}
return stage;
}
int column(const QString &stage) int column(const QString &stage)
{ {
static const QHash<QString, int> columns{ static const QHash<QString, int> columns{
+11 -1
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once #pragma once
#include <QColor> #include <QColor>
@@ -20,6 +25,11 @@ QStringList canonicalStages();
bool isValid(const QString &stage); bool isValid(const QString &stage);
/// Canonical-case spelling for a stage matched case-insensitively
/// ("rejected" -> "Rejected", "start" -> Start). Unknown input is returned
/// unchanged.
QString canonical(const QString &stage);
/// Sankey column index. Start = 0; Applied..Offer walk the funnel; the three /// Sankey column index. Start = 0; Applied..Offer walk the funnel; the three
/// terminal outcomes (Accepted/Rejected/Withdrawn/Ghosted) share the last /// terminal outcomes (Accepted/Rejected/Withdrawn/Ghosted) share the last
/// column so a rejection right after Applied is still a valid (longer) link. /// column so a rejection right after Applied is still a valid (longer) link.
+40 -8
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "kareer-version.h" #include "kareer-version.h"
#include "clicommands.h" #include "clicommands.h"
@@ -20,24 +25,37 @@ using namespace Qt::Literals::StringLiterals;
// Filter out a couple of well-known benign framework artifacts rather than // Filter out a couple of well-known benign framework artifacts rather than
// spamming every run. Everything else is passed through untouched. // spamming every run. Everything else is passed through untouched.
static QtMessageHandler s_defaultMessageHandler = nullptr; static bool isBenignFrameworkNoise(const QString &message)
static void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &message)
{ {
// Qt Quick emits this while Kirigami's PageRow incubates pages. It fires even // 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. // 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"))) { if (message.contains(QLatin1String("was not placed in the graphics scene"))) {
return; return true;
} }
// Malformed path data in third-party icon SVGs (system/app icon themes) is not // An internal PageRow/StackView implementation detail, not something app
// actionable from here; drop the noise rather than spam every render. // code can influence.
if (context.category && qstrcmp(context.category, "qt.svg") == 0) { if (message.contains(QLatin1String("StackView has detected conflicting anchors"))) {
return; return true;
} }
// Qt's Wayland integration tries to self-register with xdg-desktop-portal for // Qt's Wayland integration tries to self-register with xdg-desktop-portal for
// optional desktop features (global shortcuts, background). Kareer doesn't use // optional desktop features (global shortcuts, background). Kareer doesn't use
// any of those, and it fires harmlessly on hosts where portal app-info // any of those, and it fires harmlessly on hosts where portal app-info
// resolution is finicky. // resolution is finicky.
if (message.contains(QLatin1String("Failed to register with host portal"))) { if (message.contains(QLatin1String("Failed to register with host portal"))) {
return true;
}
return false;
}
static QtMessageHandler s_defaultMessageHandler = nullptr;
static void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &message)
{
if (isBenignFrameworkNoise(message)) {
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; return;
} }
if (s_defaultMessageHandler) { if (s_defaultMessageHandler) {
@@ -87,6 +105,20 @@ int main(int argc, char *argv[])
QQmlApplicationEngine engine; QQmlApplicationEngine engine;
KLocalization::setupLocalizedContext(&engine); KLocalization::setupLocalizedContext(&engine);
QObject::connect(&engine, &QQmlApplicationEngine::warnings, &engine, [](const QList<QQmlError> &warnings) {
for (const QQmlError &error : warnings) {
if (isBenignFrameworkNoise(error.description())) {
continue;
}
fprintf(stderr, "QML-WARNING: %s\n", qPrintable(error.toString()));
}
fflush(stderr);
});
QObject::connect(&engine, &QQmlApplicationEngine::objectCreationFailed, &engine, [](const QUrl &url) {
fprintf(stderr, "QML-OBJECT-CREATION-FAILED: %s\n", qPrintable(url.toString()));
fflush(stderr);
});
engine.loadFromModule("io.github.toservetheking.Kareer", u"Main"_s); engine.loadFromModule("io.github.toservetheking.Kareer", u"Main"_s);
if (engine.rootObjects().isEmpty()) { if (engine.rootObjects().isEmpty()) {
return -1; return -1;
-216
View File
@@ -1,216 +0,0 @@
// 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();
}
}
]
}
}
+317
View File
@@ -0,0 +1,317 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
pragma ComponentBehavior: Bound
// Styled after KDE System Settings' Audio page (plasma-pa's kcm/ui/main.qml):
// Kirigami.ListSectionHeader per category, flat rows indented under the
// header and separated by Kirigami.Separator, no card/border around them.
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import Qt.labs.qmlmodels
import org.kde.kirigami as Kirigami
import org.kde.kitemmodels as KItemModels
import org.kde.kirigamiaddons.dateandtime as DateTime
import io.github.toservetheking.Kareer
Kirigami.ScrollablePage {
id: root
required property JobsModel jobsModel
property int editingJobId: -1
title: root.editingJobId < 0 ? i18nc("@title", "Add Application") : i18nc("@title", "Edit Application")
signal done()
leftPadding: 0
rightPadding: 0
// No topPadding: Kirigami.ListSectionHeader (the first thing on the
// page) already carries its own top padding, and stacking ours on top
// of that left an oversized gap above "Company", the first category.
bottomPadding: Kirigami.Units.gridUnit
JobEditModel {
id: editModel
jobsModel: root.jobsModel
editingJobId: root.editingJobId
}
actions: [
Kirigami.Action {
text: i18nc("@action:button", "Cancel")
icon.name: "dialog-cancel"
onTriggered: root.done()
},
Kirigami.Action {
text: i18nc("@action:button", "Delete")
icon.name: "edit-delete"
visible: root.editingJobId >= 0
onTriggered: deleteConfirmDialog.open()
},
Kirigami.Action {
text: i18nc("@action:button", "Save")
icon.name: "document-save"
onTriggered: {
if (editModel.save()) {
root.done();
} else {
errorLabel.text = editModel.lastError;
}
}
}
]
ColumnLayout {
spacing: 0
Kirigami.InlineMessage {
id: errorLabel
Layout.fillWidth: true
Layout.leftMargin: Kirigami.Units.largeSpacing
Layout.rightMargin: Kirigami.Units.largeSpacing
Layout.bottomMargin: Kirigami.Units.smallSpacing
type: Kirigami.MessageType.Error
visible: text.length > 0
}
Repeater {
model: editModel.categories
delegate: ColumnLayout {
id: section
required property var modelData
Layout.fillWidth: true
spacing: 0
Kirigami.ListSectionHeader {
Layout.fillWidth: true
text: section.modelData.title
}
KItemModels.KSortFilterProxyModel {
id: catModel
sourceModel: editModel
filterRoleName: "categoryId"
// No category id is a prefix of another, so this is an exact match.
filterString: section.modelData.id
}
ColumnLayout {
Layout.fillWidth: true
Layout.leftMargin: Kirigami.Units.largeSpacing
Layout.rightMargin: Kirigami.Units.largeSpacing
Layout.topMargin: Kirigami.Units.smallSpacing
Layout.bottomMargin: Kirigami.Units.smallSpacing
spacing: Kirigami.Units.largeSpacing
Repeater {
id: rowRepeater
model: catModel
delegate: DelegateChooser {
role: "rowType"
// Text
DelegateChoice {
roleValue: 0
delegate: ColumnLayout {
id: textD
required property int index
required property string label
required property string value
required property string placeholder
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
QQC2.Label {
Layout.fillWidth: true
text: textD.label
}
QQC2.TextField {
Layout.fillWidth: true
text: textD.value
placeholderText: textD.placeholder
onTextEdited: editModel.setValue(textD.sourceRow, text)
}
Kirigami.Separator {
Layout.fillWidth: true
Layout.topMargin: Kirigami.Units.smallSpacing
visible: textD.index !== rowRepeater.count - 1
}
}
}
// Combo box
DelegateChoice {
roleValue: 1
delegate: ColumnLayout {
id: comboD
required property int index
required property string label
required property string value
required property var comboOptions
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
QQC2.Label {
Layout.fillWidth: true
text: comboD.label
}
QQC2.ComboBox {
Layout.fillWidth: true
model: comboD.comboOptions
currentIndex: comboD.comboOptions.indexOf(comboD.value)
onActivated: editModel.setValue(comboD.sourceRow, currentText)
}
Kirigami.Separator {
Layout.fillWidth: true
Layout.topMargin: Kirigami.Units.smallSpacing
visible: comboD.index !== rowRepeater.count - 1
}
}
}
// Spin box
DelegateChoice {
roleValue: 2
delegate: ColumnLayout {
id: spinD
required property int index
required property string label
required property var model
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
QQC2.Label {
Layout.fillWidth: true
text: spinD.label
}
QQC2.SpinBox {
id: spinBox
Layout.fillWidth: true
from: spinD.model.spinMin
to: spinD.model.spinMax
stepSize: 1000
// One-time init, not a persistent binding: this also
// writes back on user edits, so binding "value" live to
// spinD.model.value would be a binding loop.
Component.onCompleted: value = spinD.model.value
onValueChanged: editModel.setValue(spinD.sourceRow, value)
}
Kirigami.Separator {
Layout.fillWidth: true
Layout.topMargin: Kirigami.Units.smallSpacing
visible: spinD.index !== rowRepeater.count - 1
}
}
}
// Date
DelegateChoice {
roleValue: 3
delegate: ColumnLayout {
id: dateD
required property int index
required property string label
required property var value
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
QQC2.Label {
Layout.fillWidth: true
text: dateD.label
}
QQC2.Button {
Layout.fillWidth: true
text: Qt.formatDate(dateD.value, Qt.ISODate)
icon.name: "view-calendar-day"
onClicked: {
datePopup.value = dateD.value;
datePopup.open();
}
DateTime.DatePopup {
id: datePopup
onAccepted: editModel.setValue(dateD.sourceRow, value)
}
}
Kirigami.Separator {
Layout.fillWidth: true
Layout.topMargin: Kirigami.Units.smallSpacing
visible: dateD.index !== rowRepeater.count - 1
}
}
}
// Text area
DelegateChoice {
roleValue: 4
delegate: ColumnLayout {
id: areaD
required property int index
required property string label
required property string value
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
QQC2.Label {
Layout.fillWidth: true
text: areaD.label
}
QQC2.TextArea {
Layout.fillWidth: true
Layout.preferredHeight: Kirigami.Units.gridUnit * 5
wrapMode: TextEdit.Wrap
text: areaD.value
onTextChanged: editModel.setValue(areaD.sourceRow, text)
}
Kirigami.Separator {
Layout.fillWidth: true
Layout.topMargin: Kirigami.Units.smallSpacing
visible: areaD.index !== rowRepeater.count - 1
}
}
}
}
}
}
}
}
}
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: {
editModel.deleteJob();
deleteConfirmDialog.close();
root.done();
}
}
]
}
}
+17 -30
View File
@@ -1,6 +1,14 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
// Sidebar/detail two-column layout: a search-filtered list of applications
// with a RoundedItemDelegate + SubtitleContentItem delegate, where the
// currently-open entry stays highlighted.
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import org.kde.kirigami as Kirigami import org.kde.kirigami as Kirigami
@@ -12,7 +20,9 @@ Kirigami.ScrollablePage {
id: root id: root
required property JobsModel jobsModel required property JobsModel jobsModel
required property var editDialog property int currentJobId: -1
signal editRequested(int jobId)
title: i18nc("@title", "Applications") title: i18nc("@title", "Applications")
@@ -21,14 +31,6 @@ Kirigami.ScrollablePage {
onTextChanged: filteredJobs.filterString = text onTextChanged: filteredJobs.filterString = text
} }
actions: [
Kirigami.Action {
text: i18nc("@action:button", "Add Application")
icon.name: "list-add"
onTriggered: root.editDialog.openForAdd()
}
]
KItemModels.KSortFilterProxyModel { KItemModels.KSortFilterProxyModel {
id: filteredJobs id: filteredJobs
sourceModel: root.jobsModel sourceModel: root.jobsModel
@@ -49,32 +51,17 @@ Kirigami.ScrollablePage {
required property string company required property string company
required property string title required property string title
required property string stage required property string stage
required property var dateApplied
required property int salaryMin
required property int salaryMax
required property string currency
text: jobDelegate.company text: jobDelegate.company
icon.source: "network-workgroup-symbolic"
highlighted: root.currentJobId === jobDelegate.jobId
contentItem: Delegates.SubtitleContentItem { contentItem: Delegates.SubtitleContentItem {
itemDelegate: jobDelegate itemDelegate: jobDelegate
subtitle: { subtitle: jobDelegate.title + " · " + jobDelegate.stage
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) onClicked: root.editRequested(jobDelegate.jobId)
} }
Kirigami.PlaceholderMessage { Kirigami.PlaceholderMessage {
@@ -83,7 +70,7 @@ Kirigami.ScrollablePage {
visible: jobList.count === 0 visible: jobList.count === 0
icon.name: "office-address-book-symbolic" icon.name: "office-address-book-symbolic"
text: i18n("No applications yet") text: i18n("No applications yet")
explanation: i18n("Use the Add Application button to log your first one.") explanation: i18n("Use the Add Application button on the dashboard to log your first one.")
} }
} }
} }
+17 -2
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import QtQuick import QtQuick
@@ -12,8 +17,18 @@ Kirigami.ScrollablePage {
required property JobsModel jobsModel required property JobsModel jobsModel
signal addRequested()
title: i18nc("@title", "Dashboard") title: i18nc("@title", "Dashboard")
actions: [
Kirigami.Action {
text: i18nc("@action:button", "Add Application")
icon.name: "list-add"
onTriggered: root.addRequested()
}
]
StatsModel { StatsModel {
id: statsModel id: statsModel
} }
@@ -57,7 +72,7 @@ Kirigami.ScrollablePage {
} }
QQC2.Label { QQC2.Label {
text: statCard.modelData.label text: statCard.modelData.label
opacity: 0.7 color: Kirigami.Theme.disabledTextColor
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
Layout.fillWidth: true Layout.fillWidth: true
} }
+74 -8
View File
@@ -1,8 +1,14 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import org.kde.kirigami as Kirigami import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.formcard as FormCard
import io.github.toservetheking.Kareer import io.github.toservetheking.Kareer
Kirigami.ApplicationWindow { Kirigami.ApplicationWindow {
@@ -19,22 +25,73 @@ Kirigami.ApplicationWindow {
id: jobsModel id: jobsModel
} }
ApplicationEditDialog { // The job currently open in the edit form, so the sidebar can keep it highlighted.
id: editDialog property int currentJobId: -1
jobsModel: jobsModel
}
pageStack.defaultColumnWidth: Kirigami.Units.gridUnit * 22 pageStack.defaultColumnWidth: Kirigami.Units.gridUnit * 16
pageStack.globalToolBar.style: Kirigami.ApplicationHeaderStyle.ToolBar pageStack.globalToolBar.style: Kirigami.ApplicationHeaderStyle.ToolBar
// Two static columns (applications + dashboard) - no dynamic page pushing. globalDrawer: Kirigami.GlobalDrawer {
isMenu: true
actions: [
Kirigami.Action {
text: i18nc("@action:inmenu", "Preferences…")
icon.name: "configure"
onTriggered: root.pageStack.pushDialogLayer(settingsPageComponent, {
width: root.width
}, {
width: Kirigami.Units.gridUnit * 24,
height: Kirigami.Units.gridUnit * 20,
modality: Qt.NonModal
})
},
Kirigami.Action {
text: i18nc("@action:inmenu", "About %1", root.title)
icon.name: "help-about"
onTriggered: root.pageStack.pushDialogLayer(aboutPageComponent, {
width: root.width
}, {
width: Kirigami.Units.gridUnit * 30,
height: Kirigami.Units.gridUnit * 30,
modality: Qt.NonModal
})
}
]
}
Component {
id: aboutPageComponent
FormCard.AboutPage {}
}
Component {
id: settingsPageComponent
SettingsPage {}
}
// Two static columns: the applications list (sidebar) and the dashboard.
// "Add Application" (and clicking a row) replaces just the dashboard
// column with the edit form - the sidebar and its list are untouched.
pageStack.initialPage: [applicationsComponent, dashboardComponent] pageStack.initialPage: [applicationsComponent, dashboardComponent]
function showDashboard(): void {
root.currentJobId = -1;
root.pageStack.currentIndex = 1;
root.pageStack.replace(dashboardComponent);
}
function showEditPage(jobId: int): void {
root.currentJobId = jobId;
root.pageStack.currentIndex = 1;
root.pageStack.replace(editPageComponent, {editingJobId: jobId});
}
Component { Component {
id: applicationsComponent id: applicationsComponent
ApplicationsPage { ApplicationsPage {
jobsModel: jobsModel jobsModel: jobsModel
editDialog: editDialog currentJobId: root.currentJobId
onEditRequested: jobId => root.showEditPage(jobId)
} }
} }
@@ -42,6 +99,15 @@ Kirigami.ApplicationWindow {
id: dashboardComponent id: dashboardComponent
DashboardPage { DashboardPage {
jobsModel: jobsModel jobsModel: jobsModel
onAddRequested: root.showEditPage(-1)
}
}
Component {
id: editPageComponent
ApplicationEditPage {
jobsModel: jobsModel
onDone: root.showDashboard()
} }
} }
} }
+35 -10
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import QtQuick import QtQuick
@@ -13,21 +18,29 @@ Item {
id: root id: root
readonly property bool empty: sankeyModel.empty readonly property bool empty: sankeyModel.empty
readonly property real nodeWidth: Math.round(Kirigami.Units.gridUnit * 0.75)
readonly property real rowPadding: Kirigami.Units.gridUnit
SankeyModel { SankeyModel {
id: sankeyModel id: sankeyModel
} }
function refresh(): void { function refresh(): void {
if (root.width > 0 && root.height > 0) { sankeyModel.reload(root.width, root.height, root.nodeWidth, root.rowPadding);
sankeyModel.relayout(root.width, root.height);
}
} }
onWidthChanged: refresh() // Resizes only need fresh geometry, not a database re-read, and are
onHeightChanged: refresh() // debounced so a window drag doesn't rebuild every delegate per pixel.
onWidthChanged: relayoutTimer.restart()
onHeightChanged: relayoutTimer.restart()
Component.onCompleted: refresh() Component.onCompleted: refresh()
Timer {
id: relayoutTimer
interval: 150
onTriggered: sankeyModel.relayout(root.width, root.height, root.nodeWidth, root.rowPadding)
}
Kirigami.PlaceholderMessage { Kirigami.PlaceholderMessage {
anchors.centerIn: parent anchors.centerIn: parent
width: parent.width - Kirigami.Units.gridUnit * 4 width: parent.width - Kirigami.Units.gridUnit * 4
@@ -41,7 +54,6 @@ Item {
model: sankeyModel.links model: sankeyModel.links
delegate: Shape { delegate: Shape {
required property var modelData required property var modelData
asynchronous: true
ShapePath { ShapePath {
fillColor: modelData.color fillColor: modelData.color
strokeColor: "transparent" strokeColor: "transparent"
@@ -74,14 +86,27 @@ Item {
hoverEnabled: true hoverEnabled: true
} }
// Subtle backing so the label stays legible where ribbons pass
// underneath it.
Rectangle {
anchors.fill: labelHeading
anchors.margins: -Kirigami.Units.smallSpacing / 2
radius: 3
color: Kirigami.Theme.backgroundColor
opacity: 0.6
}
Kirigami.Heading { Kirigami.Heading {
id: labelHeading
level: 5 level: 5
anchors.left: parent.right anchors.left: nodeDelegate.modelData.labelOnRight ? parent.right : undefined
anchors.right: nodeDelegate.modelData.labelOnRight ? undefined : parent.left
anchors.leftMargin: Kirigami.Units.smallSpacing anchors.leftMargin: Kirigami.Units.smallSpacing
anchors.rightMargin: Kirigami.Units.smallSpacing
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
width: nodeDelegate.modelData.labelWidth width: Math.min(nodeDelegate.modelData.labelWidth, implicitWidth)
horizontalAlignment: nodeDelegate.modelData.labelOnRight ? Text.AlignLeft : Text.AlignRight
text: nodeDelegate.modelData.label text: nodeDelegate.modelData.label
visible: nodeDelegate.height >= Kirigami.Units.gridUnit
elide: Text.ElideRight elide: Text.ElideRight
} }
} }
+79
View File
@@ -0,0 +1,79 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
pragma ComponentBehavior: Bound
// Styled after KDE System Settings' Audio page (plasma-pa's kcm/ui/main.qml):
// Kirigami.ListSectionHeader per section, flat rows indented under the
// header, no card/border around them.
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import io.github.toservetheking.Kareer
Kirigami.ScrollablePage {
id: root
title: i18nc("@title:window", "Preferences")
leftPadding: 0
rightPadding: 0
// No topPadding: Kirigami.ListSectionHeader (the first thing on the
// page) already carries its own top padding, and stacking ours on top
// of that left an oversized gap above "Appearance".
bottomPadding: Kirigami.Units.gridUnit
ColumnLayout {
width: root.width
spacing: 0
Kirigami.ListSectionHeader {
Layout.fillWidth: true
text: i18nc("@title:group", "Appearance")
}
ColumnLayout {
Layout.fillWidth: true
Layout.leftMargin: Kirigami.Units.largeSpacing
Layout.rightMargin: Kirigami.Units.largeSpacing
Layout.topMargin: Kirigami.Units.smallSpacing
Layout.bottomMargin: Kirigami.Units.smallSpacing
spacing: Kirigami.Units.smallSpacing
QQC2.Label {
Layout.fillWidth: true
text: i18nc("@label:listbox", "Color scheme")
}
QQC2.ComboBox {
id: colorSchemeCombo
Layout.fillWidth: true
model: AppColorScheme.colorSchemesModel
textRole: "display"
delegate: QQC2.ItemDelegate {
id: schemeDelegate
required property var model
width: colorSchemeCombo.width
icon.source: "image://colorScheme/" + schemeDelegate.model.display
icon.color: "transparent"
text: schemeDelegate.model.display
highlighted: schemeDelegate.model.display === AppColorScheme.activeColorSchemeName
onClicked: {
AppColorScheme.activeColorSchemeName = schemeDelegate.model.display;
colorSchemeCombo.popup.close();
}
}
// Keep the closed-box label in sync without fighting the popup's own selection state.
displayText: AppColorScheme.activeColorSchemeName
}
}
}
}
+208 -51
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "sankeymodel.h" #include "sankeymodel.h"
#include "jobstage.h" #include "jobstage.h"
@@ -10,6 +15,7 @@
#include <QVariantMap> #include <QVariantMap>
#include <algorithm> #include <algorithm>
#include <iterator> #include <iterator>
#include <numeric>
using namespace Qt::Literals::StringLiterals; using namespace Qt::Literals::StringLiterals;
@@ -57,25 +63,23 @@ bool SankeyModel::isEmpty() const
return m_nodes.isEmpty(); return m_nodes.isEmpty();
} }
void SankeyModel::relayout(qreal width, qreal height) void SankeyModel::reload(qreal width, qreal height, qreal nodeWidth, qreal padding)
{
m_counts.clear();
const QList<StageTransition> transitions = m_db.stageTransitions();
for (const StageTransition &t : transitions) {
const QString from = t.fromStage.isEmpty() ? QString::fromLatin1(JobStage::Start) : t.fromStage;
m_counts[{from, t.toStage}] += 1;
}
relayout(width, height, nodeWidth, padding);
}
void SankeyModel::relayout(qreal width, qreal height, qreal nodeWidth, qreal padding)
{ {
m_nodes.clear(); m_nodes.clear();
m_links.clear(); m_links.clear();
if (width <= 0 || height <= 0) { if (width <= 0 || height <= 0 || m_counts.isEmpty()) {
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(); Q_EMIT changed();
return; return;
} }
@@ -83,7 +87,7 @@ void SankeyModel::relayout(qreal width, qreal height)
QHash<QString, int> inbound; QHash<QString, int> inbound;
QHash<QString, int> outbound; QHash<QString, int> outbound;
QSet<QString> stageNames; QSet<QString> stageNames;
for (auto it = counts.constBegin(); it != counts.constEnd(); ++it) { for (auto it = m_counts.constBegin(); it != m_counts.constEnd(); ++it) {
const QString &from = it.key().first; const QString &from = it.key().first;
const QString &to = it.key().second; const QString &to = it.key().second;
outbound[from] += it.value(); outbound[from] += it.value();
@@ -111,14 +115,23 @@ void SankeyModel::relayout(qreal width, qreal height)
}); });
QHash<int, QList<int>> columnIndices; QHash<int, QList<int>> columnIndices;
int maxColumn = 0;
for (int i = 0; i < nodeList.size(); ++i) { for (int i = 0; i < nodeList.size(); ++i) {
columnIndices[nodeList.at(i).column].append(i); columnIndices[nodeList.at(i).column].append(i);
maxColumn = qMax(maxColumn, nodeList.at(i).column);
} }
constexpr qreal nodeWidth = 16.0; // Columns are spread by their rank among the columns actually present,
constexpr qreal padding = 10.0; // not their canonical index, so a sparse pipeline (say Start, Applied,
// Rejected) still fills the width instead of leaving dead stretches for
// the unused stages in between.
QList<int> usedColumns = columnIndices.keys();
std::sort(usedColumns.begin(), usedColumns.end());
QHash<int, int> rankOfColumn;
for (int i = 0; i < usedColumns.size(); ++i) {
rankOfColumn.insert(usedColumns.at(i), i);
}
const int rankCount = usedColumns.size();
constexpr qreal minNodeHeight = 2.0;
// A single vertical scale is shared by every column: the column with the // A single vertical scale is shared by every column: the column with the
// largest total flow determines it, so no column can overflow the // largest total flow determines it, so no column can overflow the
@@ -145,15 +158,59 @@ void SankeyModel::relayout(qreal width, qreal height)
scale = 1.0; scale = 1.0;
} }
// Tiny nodes get clamped up to minNodeHeight, which consumes height the
// raw totals above didn't account for; shrink the shared scale until
// every column fits with its clamped nodes included. Terminates because
// the scale only decreases and the clamped set only grows.
bool again = haveScale;
while (again) {
again = false;
for (auto it = columnIndices.constBegin(); it != columnIndices.constEnd(); ++it) {
const qreal gaps = padding * qMax(0, it.value().size() - 1);
qreal clampedHeight = 0;
qreal freeTotal = 0;
for (int idx : it.value()) {
const qreal value = nodeList.at(idx).value;
if (value * scale < minNodeHeight) {
clampedHeight += minNodeHeight;
} else {
freeTotal += value;
}
}
if (freeTotal <= 0) {
continue;
}
const qreal available = height - gaps - clampedHeight;
if (available <= 0) {
continue;
}
const qreal candidate = available / freeTotal;
if (candidate < scale) {
scale = candidate;
again = true;
}
}
}
// Per-column minimum node height: backs off from minNodeHeight when even
// that many clamped nodes would overflow a very short viewport.
QHash<int, qreal> minHeightByColumn;
for (auto it = columnIndices.constBegin(); it != columnIndices.constEnd(); ++it) {
const int count = it.value().size();
const qreal gaps = padding * qMax(0, count - 1);
minHeightByColumn.insert(it.key(), qMin(minNodeHeight, qMax<qreal>(0.5, (height - gaps) / count)));
}
for (auto it = columnIndices.begin(); it != columnIndices.end(); ++it) { for (auto it = columnIndices.begin(); it != columnIndices.end(); ++it) {
const QList<int> &idxs = it.value(); const QList<int> &idxs = it.value();
qreal totalHeight = 0; const qreal minHeight = minHeightByColumn.value(it.key());
for (int idx : idxs) { // Columns are top-aligned rather than centered: the funnel's success
totalHeight += qMax<qreal>(nodeList.at(idx).value * scale, 2.0); // path then runs level along the top while drop-off ribbons peel
} // downward into the open space beneath it, instead of every column
const qreal gaps = padding * qMax(0, idxs.size() - 1); // being centered and the ribbons weaving up and down to meet.
const qreal startY = qMax<qreal>(0.0, (height - totalHeight - gaps) / 2.0); const qreal startY = 0.0;
const qreal x = maxColumn > 0 ? (it.key() * (width - nodeWidth) / maxColumn) : 0.0; const int rank = rankOfColumn.value(it.key());
const qreal x = rankCount > 1 ? rank * (width - nodeWidth) / (rankCount - 1) : (width - nodeWidth) / 2.0;
qreal cursorY = startY; qreal cursorY = startY;
for (int idx : idxs) { for (int idx : idxs) {
@@ -161,7 +218,7 @@ void SankeyModel::relayout(qreal width, qreal height)
n.x = x; n.x = x;
n.y = cursorY; n.y = cursorY;
n.width = nodeWidth; n.width = nodeWidth;
n.height = qMax<qreal>(n.value * scale, 2.0); n.height = qMax(n.value * scale, minHeight);
cursorY += n.height + padding; cursorY += n.height + padding;
} }
} }
@@ -171,14 +228,7 @@ void SankeyModel::relayout(qreal width, qreal height)
nodeIndexByStage.insert(nodeList.at(i).stage, i); nodeIndexByStage.insert(nodeList.at(i).stage, i);
} }
QHash<QString, qreal> sourceCursor; QList<QPair<QString, QString>> linkKeys = m_counts.keys();
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) { 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 aFrom = nodeIndexByStage.value(a.first);
const int bFrom = nodeIndexByStage.value(b.first); const int bFrom = nodeIndexByStage.value(b.first);
@@ -188,22 +238,107 @@ void SankeyModel::relayout(qreal width, qreal height)
return nodeIndexByStage.value(a.second) < nodeIndexByStage.value(b.second); return nodeIndexByStage.value(a.second) < nodeIndexByStage.value(b.second);
}); });
// Ribbons share the node's vertical scale, but their minimum visible
// thickness can add up past the node it stacks against; total the raw
// thicknesses per node and side first, then squeeze each ribbon by its
// endpoints' overflow so the stack always stays inside both nodes.
QList<qreal> rawThickness;
rawThickness.reserve(linkKeys.size());
QHash<QString, qreal> outboundThickness;
QHash<QString, qreal> inboundThickness;
for (const auto &key : std::as_const(linkKeys)) { for (const auto &key : std::as_const(linkKeys)) {
const NodeInfo &fromNode = nodeList.at(nodeIndexByStage.value(key.first));
const NodeInfo &toNode = nodeList.at(nodeIndexByStage.value(key.second));
const qreal minThickness = qMin<qreal>(1.5, qMin(minHeightByColumn.value(fromNode.column), minHeightByColumn.value(toNode.column)));
const qreal thickness = qMax(m_counts.value(key) * scale, minThickness);
rawThickness.append(thickness);
outboundThickness[key.first] += thickness;
inboundThickness[key.second] += thickness;
}
QList<qreal> finalThickness;
finalThickness.reserve(linkKeys.size());
for (int i = 0; i < linkKeys.size(); ++i) {
const QPair<QString, QString> &key = linkKeys.at(i);
const NodeInfo &fromNode = nodeList.at(nodeIndexByStage.value(key.first));
const NodeInfo &toNode = nodeList.at(nodeIndexByStage.value(key.second));
const qreal outFactor = qMin<qreal>(1.0, fromNode.height / outboundThickness.value(key.first));
const qreal inFactor = qMin<qreal>(1.0, toNode.height / inboundThickness.value(key.second));
finalThickness.append(rawThickness.at(i) * qMin(outFactor, inFactor));
}
// Each end of a ribbon gets its slot on the node independently: outgoing
// ribbons stack in order of their target's height, incoming ones in
// order of their source's height (d3-sankey style). One global order for
// both ends would let a low slot head for a high target and twist over
// its siblings.
const auto nodeCenter = [&](const QString &stage) {
const NodeInfo &n = nodeList.at(nodeIndexByStage.value(stage));
return n.y + n.height / 2.0;
};
QList<int> order(linkKeys.size());
std::iota(order.begin(), order.end(), 0);
QList<qreal> sourceYTop(linkKeys.size());
std::sort(order.begin(), order.end(), [&](int a, int b) {
const QPair<QString, QString> &ka = linkKeys.at(a);
const QPair<QString, QString> &kb = linkKeys.at(b);
if (ka.first != kb.first) {
return nodeIndexByStage.value(ka.first) < nodeIndexByStage.value(kb.first);
}
const qreal ya = nodeCenter(ka.second);
const qreal yb = nodeCenter(kb.second);
if (!qFuzzyCompare(ya, yb)) {
return ya < yb;
}
return nodeIndexByStage.value(ka.second) < nodeIndexByStage.value(kb.second);
});
QHash<QString, qreal> sourceCursor;
for (const NodeInfo &n : std::as_const(nodeList)) {
sourceCursor.insert(n.stage, n.y);
}
for (int i : std::as_const(order)) {
sourceYTop[i] = sourceCursor.value(linkKeys.at(i).first);
sourceCursor[linkKeys.at(i).first] = sourceYTop.at(i) + finalThickness.at(i);
}
QList<qreal> targetYTop(linkKeys.size());
std::sort(order.begin(), order.end(), [&](int a, int b) {
const QPair<QString, QString> &ka = linkKeys.at(a);
const QPair<QString, QString> &kb = linkKeys.at(b);
if (ka.second != kb.second) {
return nodeIndexByStage.value(ka.second) < nodeIndexByStage.value(kb.second);
}
const qreal ya = nodeCenter(ka.first);
const qreal yb = nodeCenter(kb.first);
if (!qFuzzyCompare(ya, yb)) {
return ya < yb;
}
return nodeIndexByStage.value(ka.first) < nodeIndexByStage.value(kb.first);
});
QHash<QString, qreal> targetCursor;
for (const NodeInfo &n : std::as_const(nodeList)) {
targetCursor.insert(n.stage, n.y);
}
for (int i : std::as_const(order)) {
targetYTop[i] = targetCursor.value(linkKeys.at(i).second);
targetCursor[linkKeys.at(i).second] = targetYTop.at(i) + finalThickness.at(i);
}
for (int i = 0; i < linkKeys.size(); ++i) {
const QPair<QString, QString> &key = linkKeys.at(i);
const QString &from = key.first; const QString &from = key.first;
const QString &to = key.second; const QString &to = key.second;
const int value = counts.value(key); const int value = m_counts.value(key);
const qreal thickness = qMax<qreal>(value * scale, 1.5);
const NodeInfo &fromNode = nodeList.at(nodeIndexByStage.value(from)); const NodeInfo &fromNode = nodeList.at(nodeIndexByStage.value(from));
const NodeInfo &toNode = nodeList.at(nodeIndexByStage.value(to)); const NodeInfo &toNode = nodeList.at(nodeIndexByStage.value(to));
const qreal y0Top = sourceCursor.value(from); const qreal thickness = finalThickness.at(i);
const qreal y0Top = sourceYTop.at(i);
const qreal y0Bottom = y0Top + thickness; const qreal y0Bottom = y0Top + thickness;
sourceCursor[from] = y0Bottom; const qreal y1Top = targetYTop.at(i);
const qreal y1Top = targetCursor.value(to);
const qreal y1Bottom = y1Top + thickness; const qreal y1Bottom = y1Top + thickness;
targetCursor[to] = y1Bottom;
const qreal x0 = fromNode.x + fromNode.width; const qreal x0 = fromNode.x + fromNode.width;
const qreal x1 = toNode.x; const qreal x1 = toNode.x;
@@ -221,14 +356,18 @@ void SankeyModel::relayout(qreal width, qreal height)
{u"value"_s, value}, {u"value"_s, value},
{u"pathData"_s, path}, {u"pathData"_s, path},
{u"color"_s, linkColor}, {u"color"_s, linkColor},
{u"thickness"_s, thickness},
{u"sourceY"_s, y0Top},
{u"targetY"_s, y1Top},
}); });
} }
// Labels are drawn to the right of each node. Without a bound, a long // Labels sit to the right of each node except in the last used column,
// label (e.g. "Applications") can run into the next column's node and // whose labels go to the left (d3-sankey style) so nothing ever renders
// its own label. Cap each node's label to the gap before the next // past the right edge. Each label is capped to the gap before the next
// distinct column actually in use (skipping empty columns), so text // column in use, and the final gap is split between the right-side label
// elides instead of overlapping. // of the penultimate column and the left-side label of the last one, so
// text elides instead of overlapping.
QList<qreal> columnStarts; QList<qreal> columnStarts;
for (const NodeInfo &n : std::as_const(nodeList)) { for (const NodeInfo &n : std::as_const(nodeList)) {
if (!columnStarts.contains(n.x)) { if (!columnStarts.contains(n.x)) {
@@ -237,12 +376,29 @@ void SankeyModel::relayout(qreal width, qreal height)
} }
std::sort(columnStarts.begin(), columnStarts.end()); std::sort(columnStarts.begin(), columnStarts.end());
constexpr qreal labelMargin = 8.0;
constexpr qreal minLabelWidth = 24.0;
for (const NodeInfo &n : std::as_const(nodeList)) { for (const NodeInfo &n : std::as_const(nodeList)) {
const QString label = n.stage == QLatin1String(JobStage::Start) ? i18n("Applications") : i18n(n.stage.toUtf8().constData()); 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 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 int lastPos = columnStarts.size() - 1;
const qreal labelWidth = qMax<qreal>(24.0, nextColumnX - (n.x + n.width) - 8.0); const bool labelOnRight = columnPos < lastPos || lastPos == 0;
qreal labelWidth = 0;
if (lastPos == 0) {
labelWidth = width - (n.x + n.width) - labelMargin;
} else if (columnPos < lastPos) {
const qreal gap = columnStarts.at(columnPos + 1) - (n.x + n.width);
// The outcome names in the last column run longer than the
// penultimate stage's, so they get the bigger share of the gap.
const qreal share = columnPos == lastPos - 1 ? 0.4 : 1.0;
labelWidth = gap * share - 2 * labelMargin;
} else {
const qreal gap = n.x - (columnStarts.at(columnPos - 1) + n.width);
labelWidth = gap * 0.6 - 2 * labelMargin;
}
labelWidth = qMax(labelWidth, minLabelWidth);
m_nodes.append(QVariantMap{ m_nodes.append(QVariantMap{
{u"stage"_s, n.stage}, {u"stage"_s, n.stage},
@@ -254,6 +410,7 @@ void SankeyModel::relayout(qreal width, qreal height)
{u"value"_s, n.value}, {u"value"_s, n.value},
{u"color"_s, JobStage::color(n.stage)}, {u"color"_s, JobStage::color(n.stage)},
{u"labelWidth"_s, labelWidth}, {u"labelWidth"_s, labelWidth},
{u"labelOnRight"_s, labelOnRight},
}); });
} }
+16 -4
View File
@@ -1,9 +1,16 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once #pragma once
#include "jobsdatabase.h" #include "jobsdatabase.h"
#include <QHash>
#include <QObject> #include <QObject>
#include <QPair>
#include <QQmlEngine> #include <QQmlEngine>
#include <QVariantList> #include <QVariantList>
@@ -34,15 +41,20 @@ public:
bool isEmpty() const; bool isEmpty() const;
public Q_SLOTS: public Q_SLOTS:
/// Recomputes node/link geometry to fit within (width, height) logical /// Re-reads the stage history from the database, then lays it out to fit
/// pixels. Call whenever the data or the available viewport changes. /// within (width, height) logical pixels. Call whenever the data changes.
void relayout(qreal width, qreal height); void reload(qreal width, qreal height, qreal nodeWidth = 16.0, qreal padding = 10.0);
/// Recomputes node/link geometry from the cached stage history. Call on
/// viewport resizes; unlike reload() this never touches the database.
void relayout(qreal width, qreal height, qreal nodeWidth = 16.0, qreal padding = 10.0);
Q_SIGNALS: Q_SIGNALS:
void changed(); void changed();
private: private:
JobsDatabase m_db; JobsDatabase m_db;
QHash<QPair<QString, QString>, int> m_counts;
QVariantList m_nodes; QVariantList m_nodes;
QVariantList m_links; QVariantList m_links;
}; };
+6 -1
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "statsmodel.h" #include "statsmodel.h"
#include "jobstage.h" #include "jobstage.h"
+6 -1
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once #pragma once
#include "jobsdatabase.h" #include "jobsdatabase.h"