Author SHA1 Message Date
austinandClaude Opus 5 eee3c59f4f Add CLAUDE.md with build, test and architecture notes
Lint / REUSE compliance (push) Successful in 3s
Build and Test / Build and run tests (push) Failing after 2s
Lint / clang-format (push) Failing after 5s
Covers the build/test/lint commands (including running one test
function and a throwaway --db database), the CLI/GUI split, database
location resolution, the setStage rule, the stage vocabulary, the lane
-based Sankey layout and its geometric test, and the data-driven edit
form. REUSE.toml annotates it as CC0 like the other docs.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01BxDf7HqD1xPnsZP8wt3NTk
2026-09-11 15:42:13 -05:00
austinandClaude Opus 5 18dd49aaba Stop the Sankey ribbons braiding: split, then rejoin
Ribbons crossed over each other on their way to the outcome column.
Every funnel stage sits alone in its column at the same height, so
ordering a node's ribbon slots by the other end's node height tied,
and the tie-break ordered them against the geometry. Early drop-offs
also left from mid-node, above later stages' drop-offs, and backward
history moves were drawn right to left over everything.

- Count one left-to-right path per application: the funnel stages it
  reached, then its current stage if that is an outcome. Ghosted then
  Rejected counts only as Rejected; moving back from Offer keeps Offer.
- Lane-based routing: drop-offs travel in lanes below each column's
  node, links skipping a stage travel above it; stacks are top-aligned
  and the whole block is centered. Every ribbon crossing a gap between
  columns shares its x endpoints, and slot/lane orders are kept equal at
  both ends of every gap, so ribbons can only cross where they rejoin
  the outcome nodes. Drop-off lanes stay level rather than rising.
- Outcomes are ordered by the average stage their ribbons come from,
  which keeps those last crossings to a minimum.
- sankeylayouttest now parses the drawn pathData and fails if any two
  ribbons overlap (the previous layout overlapped by up to 150px on the
  same data); adds forward-path counting and content-centering tests,
  replacing the slot-order and per-column-centering tests.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01BxDf7HqD1xPnsZP8wt3NTk
2026-09-11 15:42:13 -05:00
austinandClaude Opus 5 260704cf9b Add --db and let the user choose where the database lives
- `kareer --db <path>` works for the GUI and every subcommand, before
  or after the subcommand name, and creates the file if missing. main()
  strips it from argv before CLI/GUI routing; every parser declares it
  so it shows in --help.
- Path precedence: --db > KAREER_DB_PATH > the location chosen in the
  GUI (kareerrc [Database] Path, read at startup for GUI and CLI alike)
  > $XDG_DATA_HOME/kareer/kareer.sqlite.
- First GUI run (nothing forced, no file yet, or the configured file
  has gone missing): DatabaseSetupDialog offers the default location, a
  folder of the user's choice, or an existing database used in place.
  Until then JobsDatabase opens nothing.
- Preferences gains a Database section: Move to... (copies, leaves the
  original), Open Existing..., Use Default Location. Refuses foreign
  SQLite files and unwritable ones.
- Models call JobsDatabase::reopenIfPathChanged() on refresh, so
  switching databases needs no restart.
- Link KF6::ConfigCore; add a QuickDialogs2 configure-time guard; add
  kconfig to the Arch dependencies and a note to the Flatpak manifest.
- README documents --db, the precedence, and the first-run choice, and
  gains Fedora 44 build dependencies; CONTRIBUTING gets a dev recipe.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01BxDf7HqD1xPnsZP8wt3NTk
2026-09-11 15:42:00 -05:00
austinandClaude Opus 5 aa3a31f014 Fix the edit form so Save persists stage changes
Changing an application's stage and pressing Save closed the form
without saving the stage: JobsDatabase::updateJob() deliberately skips
the stage column so every move lands in stage_history, but
JobEditModel::save() never followed up with setStage(). It now does,
as the CLI's update command always has.

Also:
- Validate that company and title are non-empty and say so, instead of
  saving a blank field
- Always set lastError on failure, clear it on the next attempt, and
  bind the page's error message to it
- Reload the form's values when jobsModel is assigned, since QML does
  not guarantee it is set before editingJobId
- Bind the salary spin boxes to the model and write back only on user
  edits; guard the notes field the same way
- Add jobeditmodeltest covering load order, edits, stage history,
  salaries round-tripping, adding, and validation errors

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01BxDf7HqD1xPnsZP8wt3NTk
2026-09-11 15:41:48 -05:00
austinandClaude Fable 5 92339853d3 Bump reuse-action to v5 so CI understands REUSE.toml
Lint / clang-format (push) Failing after 5s
Lint / REUSE compliance (push) Successful in 6s
Build and Test / Build and run tests (push) Failing after 2s
reuse-action@v3 runs reuse 3.x, which predates REUSE.toml and flagged
every file the annotations cover - the actual reason the REUSE job
never passed. v5 matches the reuse 5.x used to verify locally.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019FXfh48mASRsE1WEhy9jcM
2026-08-31 19:27:02 -05:00
austinandClaude Fable 5 170129feec Fix the Lint CI workflow (broken since v0.1.2)
Lint / REUSE compliance (push) Failing after 3s
Build and Test / Build and run tests (push) Failing after 1s
Lint / clang-format (push) Failing after 5s
clang-format job: the flatpak CI image has no ECM on the host, so the
configure step that generated .clang-format could never run. Commit the
ECM-generated .clang-format instead (it is MIT, LICENSES/MIT.txt added),
drop the container, pin clang-format 22.1.8 from PyPI to match the
version the tree is formatted with, and reformat the sources to match.

REUSE job: add the missing CC0-1.0 license text, a copyright line for
the metainfo, REUSE.toml coverage for README/CONTRIBUTING/.gitignore,
and REUSE-Ignore markers around CONTRIBUTING.md's SPDX example so the
parser stops reading prose as a license declaration. reuse lint now
passes locally (54/54 files).

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019FXfh48mASRsE1WEhy9jcM
2026-08-31 19:23:01 -05:00
austinandClaude Fable 5 d9dce6f4d1 Add Arch PKGBUILD under dist/arch, pending AUR publication
Lint / clang-format (push) Failing after 1s
Lint / REUSE compliance (push) Failing after 2s
Build and Test / Build and run tests (push) Failing after 1s
The AUR is not accepting new accounts at the moment, so the package
(tested with makepkg, check() runs the full test suite) lives in-repo
until it can be pushed to aur.archlinux.org as 'kareer'.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019FXfh48mASRsE1WEhy9jcM
2026-08-31 18:57:17 -05:00
36 changed files with 2275 additions and 346 deletions
+95
View File
@@ -0,0 +1,95 @@
---
# SPDX-FileCopyrightText: 2019 Christoph Cullmann <[email protected]>
# SPDX-FileCopyrightText: 2019 Gernot Gebhard <[email protected]>
#
# SPDX-License-Identifier: MIT
# This file got automatically created by ECM, do not edit
# See https://clang.llvm.org/docs/ClangFormatStyleOptions.html for the config options
# and https://community.kde.org/Policies/Frameworks_Coding_Style#Clang-format_automatic_code_formatting
# for clang-format tips & tricks
---
Language: JavaScript
DisableFormat: true
---
Language: Json
DisableFormat: false
IndentWidth: 4
---
# Style for C++
Language: Cpp
# base is WebKit coding style: https://webkit.org/code-style-guidelines/
# below are only things set that diverge from this style!
BasedOnStyle: WebKit
# enforce C++11 (e.g. for std::vector<std::vector<lala>>
Standard: Cpp11
# 4 spaces indent
TabWidth: 4
# 2 * 80 wide lines
ColumnLimit: 160
# sort includes inside line separated groups
SortIncludes: true
# break before braces on function, namespace and class definitions.
BreakBeforeBraces: Linux
# CrlInstruction *a;
PointerAlignment: Right
# horizontally aligns arguments after an open bracket.
AlignAfterOpenBracket: Align
# don't move all parameters to new line
AllowAllParametersOfDeclarationOnNextLine: false
# no single line functions
AllowShortFunctionsOnASingleLine: None
# no single line enums
AllowShortEnumsOnASingleLine: false
# always break before you encounter multi line strings
AlwaysBreakBeforeMultilineStrings: true
# don't move arguments to own lines if they are not all on the same
BinPackArguments: false
# don't move parameters to own lines if they are not all on the same
BinPackParameters: false
# In case we have an if statement with multiple lines the operator should be at the beginning of the line
# but we do not want to break assignments
BreakBeforeBinaryOperators: NonAssignment
# format C++11 braced lists like function calls
Cpp11BracedListStyle: true
# do not put a space before C++11 braced lists
SpaceBeforeCpp11BracedList: false
# remove empty lines
KeepEmptyLinesAtTheStartOfBlocks: false
# no namespace indentation to keep indent level low
NamespaceIndentation: None
# we use template< without space.
SpaceAfterTemplateKeyword: false
# Always break after template declaration
AlwaysBreakTemplateDeclarations: true
# macros for which the opening brace stays attached.
ForEachMacros: [ foreach, Q_FOREACH, BOOST_FOREACH, forever, Q_FOREVER, QBENCHMARK, QBENCHMARK_ONCE , wl_resource_for_each, wl_resource_for_each_safe ]
# keep lambda formatting multi-line if not empty
AllowShortLambdasOnASingleLine: Empty
# We do not want clang-format to put all arguments on a new line
AllowAllArgumentsOnNextLine: false
+9 -6
View File
@@ -16,21 +16,24 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
# v3 ships reuse 3.x, which predates REUSE.toml support and flags
# every file the annotations cover; v5 (reuse 5.x) understands it.
- name: REUSE Compliance Check
uses: fsfe/reuse-action@v3
uses: fsfe/reuse-action@v5
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
# The .clang-format at the repo root is the one ECM's KDEClangFormat
# generates at configure time, committed so this check needs no KDE
# stack. Pinned to the clang-format major used to format the tree -
# output drifts between major versions.
- name: Install clang-format
run: pipx install clang-format==22.1.8
- name: Check formatting
run: |
-2
View File
@@ -8,5 +8,3 @@
# Editor / misc
*.user
*.autosave
# Generated by ECM's KDEClangFormat module
/.clang-format
+61
View File
@@ -0,0 +1,61 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
Kareer is a KDE-style job application tracker: C++20 backend, QML/Kirigami frontend, ECM/CMake build, Qt 6 + KDE Frameworks 6. Data is a local SQLite file. Every GUI action is also a `kareer` CLI subcommand so scripts can drive it headlessly. Licensed GPL-3.0-or-later, REUSE-compliant.
## Commands
```sh
# Configure + build (Debug, tests on). A configured build/ dir may already exist.
cmake --preset ninja-dev # or: cmake -B build -G Ninja
cmake --build build
./build/bin/kareer # GUI
./build/bin/kareer list --json # any recognized subcommand runs headless
# Tests (QtTest binaries; appstreamtest is added automatically by KDECMakeSettings)
ctest --test-dir build --output-on-failure
ctest --test-dir build -R sankeylayouttest # one test binary
./build/bin/sankeylayouttest layoutReflectsTransitionCounts # one test function
# Point the app at a throwaway DB (GUI or any subcommand; file created if missing)
./build/bin/kareer --db build/dev.sqlite add --company Acme --title Dev
./build/bin/kareer --db build/dev.sqlite
# Autotests use the env var instead: KAREER_DB_PATH=/tmp/x.sqlite
# Formatting: CI runs clang-format (pinned major 22) with the committed .clang-format
find src autotests -name '*.h' -o -name '*.cpp' | xargs clang-format --dry-run --Werror
# REUSE/SPDX check (CI): reuse lint
```
Configuring installs a clang-format git pre-commit hook via ECM if `clang-format` is on PATH. CI builds and tests inside the KDE Flatpak SDK (`flatpak-builder` with `run-tests: true` in the manifest), not a bare CMake build.
## Architecture
`src/` is intentionally flat: one class per concern, no subfolders. Backend classes are exposed to QML with `QML_ELEMENT` via the `io.github.toservetheking.Kareer` QML module declared in `src/CMakeLists.txt`. QML files are meant to be thin renderers; logic lives in C++ so it can be unit-tested.
**Entry point and CLI/GUI split.** `main.cpp` checks whether `argv[1]` is a known subcommand (`Cli::isSubcommand`). If so it builds a `QCoreApplication` and hands off to `Cli::run` in `clicommands.cpp`; otherwise it starts the Kirigami GUI. Adding a subcommand means adding it to both the list in `isSubcommand` and the dispatch in `run`, plus the README usage block.
**Database location.** `JobsDatabase::defaultPath()` resolves `--db` (stripped from argv in `main.cpp` before CLI/GUI routing) > `KAREER_DB_PATH` > the path the user chose in the GUI (`kareerrc` `[Database] Path`, loaded at startup for both GUI and CLI by `DatabaseLocation::loadConfiguredPath`) > `$XDG_DATA_HOME/kareer/kareer.sqlite`. On a GUI first run (nothing forced, file missing) `JobsDatabase::setSelectionPending(true)` makes default-constructed instances open nothing until `DatabaseSetupDialog.qml` picks a location. `DatabaseLocation` (QML singleton) writes the choice and emits `changed`; `Main.qml` then refreshes `JobsModel`, and every model calls `m_db.reopenIfPathChanged()` at the start of its refresh, so switching databases needs no restart.
**Persistence.** `JobsDatabase` is the only class that touches SQLite. Two tables: `jobs` and `stage_history`. Schema is created with `CREATE TABLE IF NOT EXISTS` in `migrate()`; there is no version number, so schema changes need an idempotent migration step there. Each `JobsDatabase` instance opens its own uniquely named `QSqlDatabase` connection, so `JobsModel`, `StatsModel` and `SankeyModel` each hold their own instance on the same file. Consequence: after a write through one model, the others do not know; QML wires this up (`DashboardPage` listens to `JobsModel.countChanged` and calls `statsModel.refresh()` and the Sankey `refresh()`).
**Stage changes must go through `setStage`.** `updateJob` deliberately does not write the stage column. `addJob` records a synthetic `Start -> stage` transition and `setStage` records every move, and that history is the sole input to the Sankey diagram. Bypassing `setStage` silently corrupts the pipeline view. The same applies to the GUI: `JobEditModel::save()` calls `updateJob` and then `setStage` when the stage combo changed (it once skipped the latter, silently dropping stage edits).
**Stage vocabulary.** `JobStage` (`jobstage.h`) is a closed, fixed list (Applied, Screening, Interview, Onsite, Offer, Accepted, Rejected, Withdrawn, Ghosted, plus the synthetic `Start`). It also owns each stage's Sankey column, in-column stacking order, color, and terminal flag. Input is canonicalized case-insensitively on write. Adding a stage touches this file, the migration's canonicalization loop, and the README.
**Sankey layout.** `SankeyModel::reload()` turns `stage_history` into one left-to-right path per job (Start, the funnel stages it reached in increasing column order, then its current stage if terminal), so backward/sideways moves never become ribbons and node values equal the number of jobs that reached them. `relayout()` computes all geometry, including SVG path strings for `QtQuick.Shapes` `PathSvg`; `SankeyDiagram.qml` only draws the `nodes`/`links` lists. Layout is lane-based: links to Rejected/Withdrawn/Ghosted are drop-offs that travel in under-lanes below each column's node; main-line links that skip a column travel in over-lanes above it; each column stack is top-aligned and the whole block is centered. Every ribbon crossing a given gap between columns uses the same x endpoints, so ribbons can only cross if their vertical order differs at the two ends of a gap; the lane/slot orderings are chosen to keep it equal everywhere except the final gap into the outcome nodes. `reload()` re-reads the DB; `relayout()` recomputes geometry from cached counts (used on resize via a debounce timer). `autotests/sankeylayouttest.cpp` checks this on the drawn geometry (it parses `pathData` and asserts ribbons never overlap) and points `KAREER_DB_PATH` at a temp file because `SankeyModel` always opens the default path.
**Edit form.** The add/edit form is data-driven rather than hand-written per field: `JobFieldCatalog` is the static list of categories and fields (id, label, row type, combo options, spin range); `JobEditModel` is a `QAbstractListModel` with one row per field holding the current values, and `ApplicationEditPage.qml` renders it with a `DelegateChooser` on `rowType`. Field ids must match the keys `JobsModel::mapFromJob` / `jobFromMap` use. Adding a form field means: `Job` struct, `JobsDatabase` columns and `jobFromQuery`, `JobsModel` roles and map conversion, `JobFieldCatalog`, and the CLI options.
**Window layout.** `Main.qml` uses a two-column `pageStack`: `ApplicationsPage` (sidebar list) is fixed, and the second column is swapped between `DashboardPage` and `ApplicationEditPage` with `pageStack.replace`. A single `JobsModel` instance is created in `Main.qml` and passed down as a property.
## Conventions
- Every source file starts with an SPDX header (`GPL-3.0-or-later` for code; docs like README/CONTRIBUTING are CC0). Files that cannot carry one are listed in `REUSE.toml`. CI fails on missing headers.
- Qt string style: `using namespace Qt::Literals::StringLiterals;` with `u"..."_s`; user-visible strings go through `i18n`/`i18nc`.
- `main.cpp` installs a message handler that drops a few known-benign Qt/Kirigami warnings; add to `isBenignFrameworkNoise` rather than silencing categories wholesale.
- Releasing: bump `project(... VERSION ...)` in `CMakeLists.txt`, add a `<release>` entry to the metainfo XML, update `pkgver`/checksum in `dist/arch/PKGBUILD`, then push a `v*` tag; `build.yml` builds, signs and publishes the Flatpak. The Flatpak manifest's `sources` block is rewritten by CI to build from the checkout.
- PRs use `.github/PULL_REQUEST_TEMPLATE.md`: reason for change, test plan, screenshots for UI changes.
+4
View File
@@ -36,12 +36,16 @@ find_package(Qt6 ${REQUIRED_QT_VERSION} REQUIRED COMPONENTS
# QtQuick.Shapes runtime QML module (SankeyDiagram.qml); configure-time
# guard only, nothing links against it.
QuickShapesPrivate
# QtQuick.Dialogs runtime QML module (DatabaseSetupDialog.qml,
# SettingsPage.qml); configure-time guard only, nothing links against it.
QuickDialogs2
QuickControls2
Sql
Test
)
find_package(KF6 ${REQUIRED_KF_VERSION} REQUIRED COMPONENTS
Config
I18n
CoreAddons
IconThemes
+18
View File
@@ -16,6 +16,22 @@ Every GUI action is also available from the `kareer` CLI
(`add|list|show|update|stage|delete|stats|stages`) — run `./build/bin/kareer
--help` to see the subcommands.
To develop against a throwaway database instead of your real one, pass
`--db` (the file is created if it doesn't exist); it works for the GUI and
every subcommand:
```sh
./build/bin/kareer --db build/dev.sqlite add --company Acme --title Engineer
./build/bin/kareer --db build/dev.sqlite
```
To see the first-run "where should the database live?" dialog, start with
empty config and data directories:
```sh
XDG_CONFIG_HOME=$(mktemp -d) XDG_DATA_HOME=$(mktemp -d) ./build/bin/kareer
```
## Test
```sh
@@ -30,10 +46,12 @@ release builds.
## Conventions
<!-- REUSE-IgnoreStart -->
- Every source file starts with `SPDX-License-Identifier: GPL-3.0-or-later`
(in whatever comment syntax fits the file type). Files that can't carry
an inline header — `.desktop`, `keys/*.asc` — are covered instead by
`REUSE.toml`.
<!-- REUSE-IgnoreEnd -->
- `src/` is intentionally flat: one class per concern, no `models/`,
`controllers/`, or `viewmodels/` subfolders.
- C++ backend classes are exposed to QML via `QML_ELEMENT`; QML views are
+121
View File
@@ -0,0 +1,121 @@
Creative Commons Legal Code
CC0 1.0 Universal
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
HEREUNDER.
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator
and subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for
the purpose of contributing to a commons of creative, cultural and
scientific works ("Commons") that the public can reliably and without fear
of later claims of infringement build upon, modify, incorporate in other
works, reuse and redistribute as freely as possible in any form whatsoever
and for any purposes, including without limitation commercial purposes.
These owners may contribute to the Commons to promote the ideal of a free
culture and the further production of creative, cultural and scientific
works, or to gain reputation or greater distribution for their Work in
part through the use and efforts of others.
For these and/or other purposes and motivations, and without any
expectation of additional consideration or compensation, the person
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
is an owner of Copyright and Related Rights in the Work, voluntarily
elects to apply CC0 to the Work and publicly distribute the Work under its
terms, with knowledge of his or her Copyright and Related Rights in the
Work and the meaning and intended legal effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not
limited to, the following:
i. the right to reproduce, adapt, distribute, perform, display,
communicate, and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or
likeness depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data
in a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation
thereof, including any amended or successor version of such
directive); and
vii. other similar, equivalent or corresponding rights throughout the
world based on applicable law or treaty, and any national
implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention
of, applicable law, Affirmer hereby overtly, fully, permanently,
irrevocably and unconditionally waives, abandons, and surrenders all of
Affirmer's Copyright and Related Rights and associated claims and causes
of action, whether now known or unknown (including existing as well as
future claims and causes of action), in the Work (i) in all territories
worldwide, (ii) for the maximum duration provided by applicable law or
treaty (including future time extensions), (iii) in any current or future
medium and for any number of copies, and (iv) for any purpose whatsoever,
including without limitation commercial, advertising or promotional
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
member of the public at large and to the detriment of Affirmer's heirs and
successors, fully intending that such Waiver shall not be subject to
revocation, rescission, cancellation, termination, or any other legal or
equitable action to disrupt the quiet enjoyment of the Work by the public
as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason
be judged legally invalid or ineffective under applicable law, then the
Waiver shall be preserved to the maximum extent permitted taking into
account Affirmer's express Statement of Purpose. In addition, to the
extent the Waiver is so judged Affirmer hereby grants to each affected
person a royalty-free, non transferable, non sublicensable, non exclusive,
irrevocable and unconditional license to exercise Affirmer's Copyright and
Related Rights in the Work (i) in all territories worldwide, (ii) for the
maximum duration provided by applicable law or treaty (including future
time extensions), (iii) in any current or future medium and for any number
of copies, and (iv) for any purpose whatsoever, including without
limitation commercial, advertising or promotional purposes (the
"License"). The License shall be deemed effective as of the date CC0 was
applied by Affirmer to the Work. Should any part of the License for any
reason be judged legally invalid or ineffective under applicable law, such
partial invalidity or ineffectiveness shall not invalidate the remainder
of the License, and in such case Affirmer hereby affirms that he or she
will not (i) exercise any of his or her remaining Copyright and Related
Rights in the Work or (ii) assert any associated claims and causes of
action with respect to the Work, in either case contrary to Affirmer's
express Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or
warranties of any kind concerning the Work, express, implied,
statutory or otherwise, including without limitation warranties of
title, merchantability, fitness for a particular purpose, non
infringement, or the absence of latent or other defects, accuracy, or
the present or absence of errors, whether or not discoverable, all to
the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without
limitation any person's Copyright and Related Rights in the Work.
Further, Affirmer disclaims responsibility for obtaining any necessary
consents, permissions or other rights required for any use of the
Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to
this CC0 or use of the Work.
+18
View File
@@ -0,0 +1,18 @@
MIT License
Copyright (c) <year> <copyright holders>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
+39 -4
View File
@@ -58,9 +58,23 @@ kareer stages
```
Run `kareer <command> --help` for the full option list of any
subcommand. Data lives in `$XDG_DATA_HOME/kareer/kareer.sqlite`
(under Flatpak, that's sandboxed to the app's own data directory); set
`KAREER_DB_PATH` to point at a different file.
subcommand.
### Where the data lives
On first launch the GUI asks where to keep the database: the default
location, a folder you choose (a synced folder, say), or an existing
`kareer.sqlite` you already have, used where it is. You can move or switch
it later under Preferences. The CLI uses the same file.
The database file is picked in this order:
1. `--db <path>` on the command line (GUI or any subcommand; the file is
created if it doesn't exist): `kareer --db ~/test.sqlite list`
2. The `KAREER_DB_PATH` environment variable
3. The location chosen in the GUI (stored in `kareerrc`)
4. `$XDG_DATA_HOME/kareer/kareer.sqlite` (under Flatpak, that's sandboxed to
the app's own data directory)
### Wiring up a resume-builder tool
@@ -94,6 +108,14 @@ flatpak install --user ./io.github.toservetheking.Kareer.flatpak
[GitHub Release]: https://github.com/toservetheking/Kareer/releases
On Arch, a `PKGBUILD` is maintained in [`dist/arch`](dist/arch/PKGBUILD)
(pending AUR publication - the AUR is not accepting new accounts right
now):
```sh
cd dist/arch && makepkg -si
```
## Building
Requires Qt 6, KDE Frameworks 6, Kirigami, Kirigami Addons and the
@@ -102,10 +124,23 @@ CMake toolchain. On Arch/CachyOS:
```sh
sudo pacman -S --needed cmake ninja extra-cmake-modules base-devel \
qt6-base qt6-declarative vulkan-headers kirigami kirigami-addons \
ki18n kcoreaddons kiconthemes kcrash kitemmodels \
ki18n kconfig kcoreaddons kiconthemes kcrash kitemmodels \
kcolorscheme qqc2-desktop-style
```
On Fedora 44:
```sh
sudo dnf install cmake ninja-build extra-cmake-modules gcc-c++ \
qt6-qtbase-devel qt6-qtbase-private-devel \
qt6-qtdeclarative-devel qt6-qtquickcontrols2-devel \
vulkan-headers kf6-kirigami-devel \
kf6-kirigami-addons-devel kf6-ki18n-devel kf6-kcoreaddons-devel \
kf6-kconfig-devel \
kf6-kiconthemes-devel kf6-kcrash-devel kf6-kitemmodels-devel \
kf6-kcolorscheme-devel qqc2-desktop-style
```
Then:
```sh
+10
View File
@@ -11,3 +11,13 @@ path = [
]
SPDX-License-Identifier = "GPL-3.0-or-later"
SPDX-FileCopyrightText = "ToServeTheKing <[email protected]>"
[[annotations]]
path = [
"README.md",
"CONTRIBUTING.md",
"CLAUDE.md",
".gitignore",
]
SPDX-License-Identifier = "CC0-1.0"
SPDX-FileCopyrightText = "ToServeTheKing <[email protected]>"
+22
View File
@@ -39,3 +39,25 @@ target_link_libraries(sankeylayouttest PRIVATE
)
add_test(NAME sankeylayouttest COMMAND sankeylayouttest)
add_executable(jobeditmodeltest
jobeditmodeltest.cpp
../src/jobstage.cpp
../src/jobsdatabase.cpp
../src/jobsmodel.cpp
../src/jobeditmodel.cpp
../src/jobfieldcatalog.cpp
)
target_include_directories(jobeditmodeltest PRIVATE ../src)
target_link_libraries(jobeditmodeltest PRIVATE
Qt6::Core
Qt6::Gui
Qt6::Qml
Qt6::Sql
Qt6::Test
KF6::I18n
)
add_test(NAME jobeditmodeltest COMMAND jobeditmodeltest)
+177
View File
@@ -0,0 +1,177 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "jobeditmodel.h"
#include "job.h"
#include "jobsdatabase.h"
#include "jobsmodel.h"
#include <QTemporaryDir>
#include <QtTest>
#include <memory>
using namespace Qt::Literals::StringLiterals;
// JobsModel always opens JobsDatabase::defaultPath(), so each test points
// that at a fresh temporary file via the KAREER_DB_PATH override.
class JobEditModelTest : public QObject
{
Q_OBJECT
private Q_SLOTS:
void init()
{
m_dir = std::make_unique<QTemporaryDir>();
QVERIFY(m_dir->isValid());
qputenv("KAREER_DB_PATH", (m_dir->path() + u"/test.sqlite"_s).toUtf8());
}
// QML does not guarantee the order ApplicationEditPage's bindings assign
// editingJobId and jobsModel; the form must load the job either way.
void loadsJobWhenIdIsSetBeforeModel()
{
const int id = seedJob();
JobsModel jobs;
JobEditModel edit;
edit.setEditingJobId(id);
edit.setJobsModel(&jobs);
QCOMPARE(valueOf(edit, u"company"_s).toString(), u"Acme Corp"_s);
QCOMPARE(valueOf(edit, u"title"_s).toString(), u"Engineer"_s);
QCOMPARE(valueOf(edit, u"salaryMin"_s).toInt(), 100000);
}
void savesEditsToExistingJob()
{
const int id = seedJob();
JobsModel jobs;
JobEditModel edit;
edit.setEditingJobId(id);
edit.setJobsModel(&jobs);
setValueOf(edit, u"title"_s, u"Senior Engineer"_s);
setValueOf(edit, u"notes"_s, u"Second round scheduled"_s);
QVERIFY2(edit.save(), qPrintable(edit.lastError()));
QVERIFY(edit.lastError().isEmpty());
JobsDatabase db;
const auto job = db.jobById(id);
QVERIFY(job.has_value());
QCOMPARE(job->company, u"Acme Corp"_s);
QCOMPARE(job->title, u"Senior Engineer"_s);
QCOMPARE(job->notes, u"Second round scheduled"_s);
// Untouched salaries must survive the round trip through the form.
QCOMPARE(job->salaryMin, 100000);
QCOMPARE(job->salaryMax, -1);
}
void stageChangeIsSavedWithHistory()
{
const int id = seedJob();
JobsModel jobs;
JobEditModel edit;
edit.setEditingJobId(id);
edit.setJobsModel(&jobs);
setValueOf(edit, u"stage"_s, u"Interview"_s);
QVERIFY2(edit.save(), qPrintable(edit.lastError()));
JobsDatabase db;
QCOMPARE(db.jobById(id)->stage, u"Interview"_s);
const auto transitions = db.stageTransitions();
QCOMPARE(transitions.size(), 2);
QCOMPARE(transitions.last().fromStage, u"Applied"_s);
QCOMPARE(transitions.last().toStage, u"Interview"_s);
}
void addsNewJob()
{
JobsModel jobs;
JobEditModel edit;
edit.setJobsModel(&jobs);
setValueOf(edit, u"company"_s, u"Globex"_s);
setValueOf(edit, u"title"_s, u"Designer"_s);
QVERIFY2(edit.save(), qPrintable(edit.lastError()));
QCOMPARE(jobs.rowCount(), 1);
JobsDatabase db;
const QList<Job> all = db.allJobs();
QCOMPARE(all.size(), 1);
QCOMPARE(all.first().company, u"Globex"_s);
QCOMPARE(all.first().stage, u"Applied"_s);
}
void missingCompanyIsReported()
{
JobsModel jobs;
JobEditModel edit;
edit.setJobsModel(&jobs);
setValueOf(edit, u"title"_s, u"Designer"_s);
QVERIFY(!edit.save());
QVERIFY(!edit.lastError().isEmpty());
QCOMPARE(jobs.rowCount(), 0);
// A later successful save clears the error.
setValueOf(edit, u"company"_s, u"Globex"_s);
QVERIFY(edit.save());
QVERIFY(edit.lastError().isEmpty());
}
void saveWithoutModelReportsError()
{
JobEditModel edit;
QVERIFY(!edit.save());
QVERIFY(!edit.lastError().isEmpty());
}
private:
int seedJob()
{
JobsDatabase db;
Job job;
job.company = u"Acme Corp"_s;
job.title = u"Engineer"_s;
job.dateApplied = QDate(2026, 6, 1);
job.salaryMin = 100000;
job.stage = u"Applied"_s;
if (!db.addJob(job)) {
qWarning() << db.lastError();
return -1;
}
return job.id;
}
static int rowOf(const JobEditModel &edit, const QString &fieldId)
{
for (int row = 0; row < edit.rowCount(); ++row) {
if (edit.data(edit.index(row), JobEditModel::FieldIdRole).toString() == fieldId) {
return row;
}
}
return -1;
}
static QVariant valueOf(const JobEditModel &edit, const QString &fieldId)
{
return edit.data(edit.index(rowOf(edit, fieldId)), JobEditModel::ValueRole);
}
static void setValueOf(JobEditModel &edit, const QString &fieldId, const QVariant &value)
{
edit.setValue(rowOf(edit, fieldId), value);
}
std::unique_ptr<QTemporaryDir> m_dir;
};
QTEST_GUILESS_MAIN(JobEditModelTest)
#include "jobeditmodeltest.moc"
+1 -1
View File
@@ -4,8 +4,8 @@
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "job.h"
#include "jobsdatabase.h"
#include "job.h"
#include <QTemporaryDir>
#include <QtTest>
+265 -31
View File
@@ -8,10 +8,13 @@
#include "jobsdatabase.h"
#include "sankeymodel.h"
#include <QPointF>
#include <QRegularExpression>
#include <QSqlDatabase>
#include <QSqlQuery>
#include <QTemporaryDir>
#include <QtTest>
#include <limits>
#include <memory>
// SankeyModel always opens JobsDatabase::defaultPath(), so each test points
@@ -173,51 +176,117 @@ private Q_SLOTS:
}
}
void ribbonSlotsFollowEndpointHeights()
void ribbonsDoNotCross_data()
{
QTest::addColumn<int>("seed");
QTest::newRow("dense pipeline") << int(DenseSeed);
QTest::newRow("links skipping stages") << int(SkipSeed);
QTest::newRow("back and sideways moves") << int(BackMoveSeed);
}
// The "braid" bug: ribbons twisting over each other. Checked on the drawn
// geometry itself: no two ribbons may overlap anywhere between their ends.
void ribbonsDoNotCross()
{
QFETCH(int, seed);
JobsDatabase db;
seedPipeline(db, seed);
SankeyModel model;
for (const QSizeF size : {QSizeF(600, 300), QSizeF(900, 500), QSizeF(400, 800)}) {
model.reload(size.width(), size.height());
QVERIFY(!model.isEmpty());
verifyNoOverlaps(model, std::numeric_limits<qreal>::max());
}
}
// With one node per outcome, ribbons from different stages into different
// outcomes can't always keep their order; any such crossing must stay in
// the final gap where they rejoin, never braid across the diagram.
void crossingsOnlyWhereRibbonsRejoin()
{
JobsDatabase db;
seedDensePipeline(db);
seedPipeline(db, LargeSeed);
SankeyModel model;
model.reload(900, 500);
QVERIFY(!model.isEmpty());
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());
QVERIFY(xs.size() >= 2);
verifyNoOverlaps(model, xs.at(xs.size() - 2) + nodeWidth);
}
// Each application is one left-to-right path: the funnel stages it
// reached, then its current stage if that is an outcome.
void historyCollapsesToForwardPaths()
{
JobsDatabase db;
seedPipeline(db, BackMoveSeed);
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
QHash<QString, int> links;
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);
links.insert(m.value(QStringLiteral("fromStage")).toString() + QStringLiteral(">") + m.value(QStringLiteral("toStage")).toString(),
m.value(QStringLiteral("value")).toInt());
}
const QHash<QString, int> expected{
{QStringLiteral("Start>Applied"), 4},
{QStringLiteral("Applied>Screening"), 2},
{QStringLiteral("Applied>Interview"), 2},
{QStringLiteral("Interview>Offer"), 1},
{QStringLiteral("Screening>Rejected"), 1},
{QStringLiteral("Screening>Withdrawn"), 1},
};
for (const auto &slots : std::as_const(outgoing)) {
verifySorted(slots);
QCOMPARE(links, expected);
for (const QVariant &v : model.nodes()) {
const QVariantMap m = v.toMap();
// Ghosted was superseded by Rejected, so it isn't an outcome anyone ended in.
QVERIFY(m.value(QStringLiteral("stage")).toString() != QStringLiteral("Ghosted"));
if (m.value(QStringLiteral("stage")).toString() == QStringLiteral("Start")) {
QCOMPARE(m.value(QStringLiteral("value")).toInt(), 4);
}
for (const auto &slots : std::as_const(incoming)) {
verifySorted(slots);
}
}
void contentIsVerticallyCentered()
{
JobsDatabase db;
seedPipeline(db, DenseSeed);
SankeyModel model;
model.reload(600, 300);
qreal top = std::numeric_limits<qreal>::max();
qreal bottom = std::numeric_limits<qreal>::lowest();
for (const QVariant &v : model.nodes()) {
const QVariantMap node = v.toMap();
top = qMin(top, node.value(QStringLiteral("y")).toReal());
bottom = qMax(bottom, node.value(QStringLiteral("y")).toReal() + node.value(QStringLiteral("height")).toReal());
}
for (const QVariant &v : model.links()) {
const Ribbon ribbon = parseRibbon(v.toMap());
for (const QList<QPointF> *edge : {&ribbon.top, &ribbon.bottom}) {
for (const QPointF &p : *edge) {
top = qMin(top, p.y());
bottom = qMax(bottom, p.y());
}
}
}
QVERIFY2(qAbs(top - (300.0 - bottom)) < 0.05, qPrintable(QStringLiteral("top margin %1, bottom margin %2").arg(top).arg(300.0 - bottom)));
}
void sparseColumnsFillWidth()
{
JobsDatabase db;
@@ -303,6 +372,171 @@ private Q_SLOTS:
}
private:
enum Seed {
DenseSeed,
SkipSeed,
BackMoveSeed,
LargeSeed,
};
static void seedPipeline(JobsDatabase &db, int seed)
{
const auto S = [](const char *stage) {
return QString::fromLatin1(stage);
};
switch (seed) {
case DenseSeed:
seedDensePipeline(db);
break;
case SkipSeed:
walkJob(db, {S("Interview"), S("Offer"), S("Accepted")});
walkJob(db, {S("Screening"), S("Interview"), S("Rejected")});
walkJob(db, {S("Screening"), S("Rejected")});
walkJob(db, {S("Screening"), S("Interview"), S("Onsite"), S("Offer"), S("Accepted")});
walkJob(db, {S("Ghosted")});
walkJob(db, {});
addJobAt(db, S("Interview"));
break;
case BackMoveSeed:
walkJob(db, {S("Screening"), S("Ghosted"), S("Rejected")});
walkJob(db, {S("Interview"), S("Offer"), S("Interview")});
walkJob(db, {S("Rejected"), S("Interview")});
walkJob(db, {S("Screening"), S("Withdrawn")});
break;
case LargeSeed:
for (int i = 0; i < 12; ++i) {
walkJob(db, {});
}
for (int i = 0; i < 6; ++i) {
walkJob(db, {S("Ghosted")});
}
for (int i = 0; i < 5; ++i) {
walkJob(db, {S("Rejected")});
}
walkJob(db, {S("Withdrawn")});
for (int i = 0; i < 4; ++i) {
walkJob(db, {S("Screening"), S("Rejected")});
}
walkJob(db, {S("Screening"), S("Ghosted")});
walkJob(db, {S("Screening")});
for (int i = 0; i < 3; ++i) {
walkJob(db, {S("Screening"), S("Interview"), S("Rejected")});
}
walkJob(db, {S("Screening"), S("Interview"), S("Withdrawn")});
walkJob(db, {S("Interview"), S("Onsite"), S("Rejected")});
walkJob(db, {S("Screening"), S("Interview"), S("Onsite"), S("Offer"), S("Accepted")});
walkJob(db, {S("Screening"), S("Interview"), S("Onsite"), S("Offer"), S("Rejected")});
walkJob(db, {S("Screening"), S("Interview"), S("Onsite"), S("Offer")});
break;
}
}
static void addJobAt(JobsDatabase &db, const QString &stage)
{
Job job;
job.company = QStringLiteral("Co");
job.title = QStringLiteral("Title");
job.stage = stage;
QVERIFY(db.addJob(job));
}
/// A ribbon's outline as drawn: its top and bottom edges, each sampled
/// left to right.
struct Ribbon {
QString name;
QList<QPointF> top;
QList<QPointF> bottom;
};
/// Parses the absolute M/C/L/Z path SankeyModel generates. The outline
/// runs along the top edge, drops straight down at the target, and runs
/// back along the bottom edge.
static Ribbon parseRibbon(const QVariantMap &link)
{
Ribbon ribbon;
ribbon.name = link.value(QStringLiteral("fromStage")).toString() + QStringLiteral("->") + link.value(QStringLiteral("toStage")).toString();
static const QRegularExpression token(QStringLiteral("[MCLZ]|-?\\d+(?:\\.\\d+)?"));
QStringList tokens;
auto it = token.globalMatch(link.value(QStringLiteral("pathData")).toString());
while (it.hasNext()) {
tokens.append(it.next().captured());
}
QList<QPointF> *edge = &ribbon.top;
QPointF current;
int i = 0;
// Read coordinates one at a time: argument evaluation order is
// unspecified, so QPointF(next(), next()) could swap x and y.
const auto nextPoint = [&]() {
const qreal x = tokens.value(i++).toDouble();
const qreal y = tokens.value(i++).toDouble();
return QPointF(x, y);
};
while (i < tokens.size()) {
const QString command = tokens.at(i++);
if (command == QLatin1String("M")) {
current = nextPoint();
edge->append(current);
} else if (command == QLatin1String("L")) {
const QPointF next = nextPoint();
if (edge == &ribbon.top && qAbs(next.x() - current.x()) < 1e-6 && qAbs(next.y() - current.y()) > 1e-6) {
edge = &ribbon.bottom; // the drop at the target
}
edge->append(next);
current = next;
} else if (command == QLatin1String("C")) {
const QPointF c1 = nextPoint();
const QPointF c2 = nextPoint();
const QPointF end = nextPoint();
constexpr int steps = 32;
for (int step = 1; step <= steps; ++step) {
const qreal t = static_cast<qreal>(step) / steps;
const qreal u = 1.0 - t;
edge->append(current * (u * u * u) + c1 * (3 * u * u * t) + c2 * (3 * u * t * t) + end * (t * t * t));
}
current = end;
}
}
std::reverse(ribbon.bottom.begin(), ribbon.bottom.end());
return ribbon;
}
static qreal yAt(const QList<QPointF> &edge, qreal x)
{
for (int i = 1; i < edge.size(); ++i) {
const QPointF &a = edge.at(i - 1);
const QPointF &b = edge.at(i);
if (x >= a.x() && x <= b.x()) {
return b.x() - a.x() < 1e-9 ? a.y() : a.y() + (b.y() - a.y()) * (x - a.x()) / (b.x() - a.x());
}
}
return x < edge.first().x() ? edge.first().y() : edge.last().y();
}
/// Fails if any two ribbons overlap vertically anywhere in the x range
/// they share, left of xLimit (their shared end points excluded).
static void verifyNoOverlaps(const SankeyModel &model, qreal xLimit)
{
QList<Ribbon> ribbons;
for (const QVariant &v : model.links()) {
ribbons.append(parseRibbon(v.toMap()));
}
for (int a = 0; a < ribbons.size(); ++a) {
for (int b = a + 1; b < ribbons.size(); ++b) {
const Ribbon &ra = ribbons.at(a);
const Ribbon &rb = ribbons.at(b);
const qreal lo = qMax(ra.top.first().x(), rb.top.first().x()) + 0.25;
const qreal hi = std::min({ra.top.last().x(), rb.top.last().x(), xLimit}) - 0.25;
for (int step = 0; step <= 400 && lo < hi; ++step) {
const qreal x = lo + (hi - lo) * step / 400.0;
const qreal overlap = qMin(yAt(ra.bottom, x), yAt(rb.bottom, x)) - qMax(yAt(ra.top, x), yAt(rb.top, x));
QVERIFY2(overlap < 0.5, qPrintable(QStringLiteral("%1 and %2 overlap by %3px at x=%4").arg(ra.name, rb.name).arg(overlap).arg(x)));
}
}
}
}
static void makeJob(JobsDatabase &db, const QString &stage)
{
Job job;
+48
View File
@@ -0,0 +1,48 @@
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
# SPDX-License-Identifier: GPL-3.0-or-later
# Maintainer: Austin Bennett <austin at thebennett dot net>
pkgname=kareer
pkgver=0.1.3
pkgrel=1
pkgdesc="Job application tracker with a Sankey pipeline view and a scriptable CLI (Kirigami)"
arch=('x86_64')
url="https://github.com/toservetheking/Kareer"
license=('GPL-3.0-or-later')
depends=(
hicolor-icon-theme
kcolorscheme
kconfig
kcoreaddons
kcrash
ki18n
kiconthemes
kirigami
kirigami-addons
qqc2-desktop-style
qt6-base
qt6-declarative
)
makedepends=(
cmake
extra-cmake-modules
)
checkdepends=(appstream)
source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
sha256sums=('7c30d4ebf2375d151e5132a57ce5b51ae7772d78a798ffa62a640114d8ff1486')
build() {
cmake -B build -S "Kareer-$pkgver" \
-DCMAKE_BUILD_TYPE=None \
-DCMAKE_INSTALL_PREFIX=/usr \
-DBUILD_TESTING=ON
cmake --build build
}
check() {
QT_QPA_PLATFORM=offscreen ctest --test-dir build --output-on-failure
}
package() {
DESTDIR="$pkgdir" cmake --install build
}
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]> -->
<!-- SPDX-License-Identifier: CC0-1.0 -->
<component type="desktop-application">
<id>io.github.toservetheking.Kareer</id>
+6 -3
View File
@@ -8,9 +8,12 @@ runtime-version: '6.10'
sdk: org.kde.Sdk
command: kareer
# A job application tracker: no host filesystem, network, or portal access is
# needed. Everything it stores lives in the app's own XDG data directory,
# which Flatpak grants by default.
# A job application tracker: no host filesystem or network access is needed.
# By default everything lives in the app's own XDG data directory, which
# Flatpak grants. A database the user places elsewhere (first-run dialog or
# Preferences) is reached through the file chooser portal instead of a
# --filesystem permission; folders are picked for new/moved databases so
# SQLite can create its journal next to the file.
finish-args:
- --share=ipc
- --socket=fallback-x11
+4
View File
@@ -21,6 +21,7 @@ qt_add_qml_module(kareer
qml/DashboardPage.qml
qml/SankeyDiagram.qml
qml/SettingsPage.qml
qml/DatabaseSetupDialog.qml
SOURCES
jobsmodel.cpp
jobsmodel.h
@@ -32,6 +33,8 @@ qt_add_qml_module(kareer
jobeditmodel.h
appcolorscheme.cpp
appcolorscheme.h
databaselocation.cpp
databaselocation.h
)
target_include_directories(kareer PRIVATE ${CMAKE_BINARY_DIR})
@@ -44,6 +47,7 @@ target_link_libraries(kareer PRIVATE
Qt6::Quick
Qt6::QuickControls2
Qt6::Sql
KF6::ConfigCore
KF6::I18n
KF6::I18nQml
KF6::CoreAddons
+30 -13
View File
@@ -26,6 +26,13 @@ using namespace Qt::Literals::StringLiterals;
namespace
{
/// --db is applied and stripped in main() before any parser runs; declaring
/// it here only documents it in each subcommand's --help.
void addDbOption(QCommandLineParser &parser)
{
parser.addOption({u"db"_s, u"Use this database file instead of the configured one"_s, u"path"_s});
}
QJsonValue optionalInt(int value)
{
return value < 0 ? QJsonValue() : QJsonValue(value);
@@ -159,6 +166,7 @@ int runAdd(const QString &program, const QStringList &args)
QCommandLineParser parser;
parser.setApplicationDescription(u"Add a new job application"_s);
parser.addHelpOption();
addDbOption(parser);
addCommonJobOptions(parser);
parser.process(QStringList{program} + args);
@@ -225,6 +233,7 @@ int runList(const QString &program, const QStringList &args)
QCommandLineParser parser;
parser.setApplicationDescription(u"List job applications"_s);
parser.addHelpOption();
addDbOption(parser);
parser.addOption({u"stage"_s, u"Filter by stage"_s, u"stage"_s});
parser.addOption({u"company"_s, u"Filter by company (substring match)"_s, u"text"_s});
parser.addOption({u"json"_s, u"Print machine-readable JSON"_s});
@@ -272,6 +281,7 @@ int runShow(const QString &program, const QStringList &args)
QCommandLineParser parser;
parser.setApplicationDescription(u"Show one job application"_s);
parser.addHelpOption();
addDbOption(parser);
parser.addOption({u"json"_s, u"Print machine-readable JSON"_s});
parser.addPositionalArgument(u"id"_s, u"Application id"_s);
parser.process(QStringList{program} + args);
@@ -310,6 +320,7 @@ int runUpdate(const QString &program, const QStringList &args)
QCommandLineParser parser;
parser.setApplicationDescription(u"Update fields on an existing application"_s);
parser.addHelpOption();
addDbOption(parser);
addCommonJobOptions(parser);
parser.addPositionalArgument(u"id"_s, u"Application id"_s);
parser.process(QStringList{program} + args);
@@ -410,6 +421,7 @@ int runStage(const QString &program, const QStringList &args)
QCommandLineParser parser;
parser.setApplicationDescription(u"Move an application to a new stage"_s);
parser.addHelpOption();
addDbOption(parser);
parser.addOption({u"json"_s, u"Print machine-readable JSON"_s});
parser.addPositionalArgument(u"id"_s, u"Application id"_s);
parser.addPositionalArgument(u"stage"_s, u"New stage: %1"_s.arg(JobStage::canonicalStages().join(u", "_s)));
@@ -453,6 +465,7 @@ int runDelete(const QString &program, const QStringList &args)
QCommandLineParser parser;
parser.setApplicationDescription(u"Delete an application"_s);
parser.addHelpOption();
addDbOption(parser);
parser.addOption({u"yes"_s, u"Confirm deletion"_s});
parser.addPositionalArgument(u"id"_s, u"Application id"_s);
parser.process(QStringList{program} + args);
@@ -490,6 +503,7 @@ int runStats(const QString &program, const QStringList &args)
QCommandLineParser parser;
parser.setApplicationDescription(u"Summary statistics across all applications"_s);
parser.addHelpOption();
addDbOption(parser);
parser.addOption({u"json"_s, u"Print machine-readable JSON"_s});
parser.process(QStringList{program} + args);
@@ -531,6 +545,7 @@ int runStages(const QString &program, const QStringList &args)
QCommandLineParser parser;
parser.setApplicationDescription(u"List the canonical pipeline stages"_s);
parser.addHelpOption();
addDbOption(parser);
parser.addOption({u"json"_s, u"Print machine-readable JSON"_s});
parser.process(QStringList{program} + args);
@@ -553,18 +568,12 @@ int runStages(const QString &program, const QStringList &args)
int runHelp()
{
QTextStream out(stdout);
out << u"Usage: kareer <command> [options]\n\n"_s
<< u"Commands:\n"_s
<< u" add Add a new job application\n"_s
<< u" list List job applications\n"_s
<< u" show Show one job application\n"_s
<< u" update Update fields on an existing application\n"_s
<< u" stage Move an application to a new stage\n"_s
<< u" delete Delete an application\n"_s
<< u" stats Summary statistics\n"_s
<< u" stages List the canonical pipeline stages\n\n"_s
<< u"Run 'kareer <command> --help' for the options of a specific command.\n"_s
<< u"Running kareer with no command (or an unrecognized one) starts the GUI.\n"_s;
out << u"Usage: kareer <command> [options]\n\n"_s << u"Commands:\n"_s << u" add Add a new job application\n"_s
<< u" list List job applications\n"_s << u" show Show one job application\n"_s << u" update Update fields on an existing application\n"_s
<< u" stage Move an application to a new stage\n"_s << u" delete Delete an application\n"_s << u" stats Summary statistics\n"_s
<< u" stages List the canonical pipeline stages\n\n"_s << u"Run 'kareer <command> --help' for the options of a specific command.\n"_s
<< u"Running kareer with no command (or an unrecognized one) starts the GUI.\n\n"_s << u"Global options:\n"_s
<< u" --db <path> Use this database file (created if missing) instead of the configured one\n"_s;
return 0;
}
@@ -573,7 +582,15 @@ int runHelp()
bool Cli::isSubcommand(const QString &arg)
{
static const QSet<QString> subcommands{
u"add"_s, u"list"_s, u"show"_s, u"update"_s, u"stage"_s, u"delete"_s, u"stats"_s, u"stages"_s, u"help"_s,
u"add"_s,
u"list"_s,
u"show"_s,
u"update"_s,
u"stage"_s,
u"delete"_s,
u"stats"_s,
u"stages"_s,
u"help"_s,
};
return subcommands.contains(arg);
}
+215
View File
@@ -0,0 +1,215 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "databaselocation.h"
#include "jobsdatabase.h"
#include <KConfigGroup>
#include <KLocalizedString>
#include <KSharedConfig>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QSqlDatabase>
using namespace Qt::Literals::StringLiterals;
namespace
{
constexpr auto ConfigFile = "kareerrc";
constexpr auto ConfigGroup = "Database";
constexpr auto ConfigKey = "Path";
constexpr auto DatabaseFileName = "kareer.sqlite";
KConfigGroup databaseGroup()
{
return KConfigGroup(KSharedConfig::openConfig(QString::fromLatin1(ConfigFile)), QString::fromLatin1(ConfigGroup));
}
QString folderFile(const QUrl &folder)
{
return QDir(folder.toLocalFile()).filePath(QString::fromLatin1(DatabaseFileName));
}
}
DatabaseLocation::DatabaseLocation(QObject *parent)
: QObject(parent)
{
if (JobsDatabase::selectionPending() && !JobsDatabase::configuredPath().isEmpty()) {
m_missingPath = JobsDatabase::configuredPath();
}
}
void DatabaseLocation::loadConfiguredPath()
{
JobsDatabase::setConfiguredPath(databaseGroup().readEntry(ConfigKey, QString()));
}
bool DatabaseLocation::needsSetup()
{
return !JobsDatabase::hasForcedPath() && !QFileInfo::exists(JobsDatabase::defaultPath());
}
QString DatabaseLocation::path() const
{
return JobsDatabase::selectionPending() ? QString() : JobsDatabase::defaultPath();
}
QString DatabaseLocation::standardPath() const
{
return JobsDatabase::standardPath();
}
bool DatabaseLocation::setupPending() const
{
return JobsDatabase::selectionPending();
}
QString DatabaseLocation::missingPath() const
{
return m_missingPath;
}
bool DatabaseLocation::overridden() const
{
return JobsDatabase::hasForcedPath();
}
QString DatabaseLocation::lastError() const
{
return m_lastError;
}
QString DatabaseLocation::lastNotice() const
{
return m_lastNotice;
}
bool DatabaseLocation::useDefault()
{
return switchTo(QString(), false);
}
bool DatabaseLocation::createIn(const QUrl &folder)
{
if (!folder.isLocalFile()) {
setMessages(i18n("Please choose a local folder."), QString());
return false;
}
return switchTo(folderFile(folder), false);
}
bool DatabaseLocation::useFile(const QUrl &file)
{
if (!file.isLocalFile()) {
setMessages(i18n("Please choose a local file."), QString());
return false;
}
return switchTo(file.toLocalFile(), true);
}
bool DatabaseLocation::moveTo(const QUrl &folder)
{
if (!folder.isLocalFile()) {
setMessages(i18n("Please choose a local folder."), QString());
return false;
}
const QString source = JobsDatabase::defaultPath();
const QString target = folderFile(folder);
if (QFileInfo(source).canonicalFilePath() == QFileInfo(target).canonicalFilePath()) {
setMessages(i18n("The database is already in that folder."), QString());
return false;
}
if (QFileInfo::exists(target)) {
setMessages(i18n("%1 already exists. Choose another folder, or open that file instead.", target), QString());
return false;
}
if (!QFile::copy(source, target)) {
setMessages(i18n("Could not copy the database to %1.", target), QString());
return false;
}
if (!switchTo(target, true)) {
QFile::remove(target);
return false;
}
setMessages(QString(), i18n("Now using %1. The previous file at %2 was left in place.", target, source));
return true;
}
void DatabaseLocation::clearMessages()
{
setMessages(QString(), QString());
}
bool DatabaseLocation::switchTo(const QString &path, bool mustExist)
{
const QString effective = path.isEmpty() ? JobsDatabase::standardPath() : path;
if (mustExist && !QFileInfo::exists(effective)) {
setMessages(i18n("%1 does not exist.", effective), QString());
return false;
}
// Refuse to add Kareer's tables to some unrelated SQLite file.
if (QFileInfo::exists(effective)) {
const QString connection = u"kareer_probe"_s;
bool foreign = false;
{
QSqlDatabase probe = QSqlDatabase::addDatabase(u"QSQLITE"_s, connection);
probe.setDatabaseName(effective);
probe.setConnectOptions(u"QSQLITE_OPEN_READONLY"_s);
if (probe.open()) {
const QStringList tables = probe.tables();
foreign = !tables.isEmpty() && !tables.contains(u"jobs"_s);
}
probe.close();
}
QSqlDatabase::removeDatabase(connection);
if (foreign) {
setMessages(i18n("%1 is not a Kareer database.", effective), QString());
return false;
}
}
{
// Opening creates the file and schema if needed; the write check
// catches files that can be read but not written (for example a
// single-file grant inside the Flatpak sandbox, where SQLite cannot
// create its journal next to the database).
JobsDatabase db(effective);
if (!db.isOpen() || !db.lastError().isEmpty() || !db.checkWritable()) {
setMessages(i18n("Could not use %1: %2", effective, db.lastError()), QString());
return false;
}
}
KConfigGroup group = databaseGroup();
if (path.isEmpty()) {
group.deleteEntry(ConfigKey);
} else {
group.writeEntry(ConfigKey, path);
}
group.sync();
JobsDatabase::setConfiguredPath(path);
JobsDatabase::setSelectionPending(false);
m_missingPath.clear();
setMessages(QString(), QString());
Q_EMIT changed();
return true;
}
void DatabaseLocation::setMessages(const QString &error, const QString &notice)
{
if (m_lastError == error && m_lastNotice == notice) {
return;
}
m_lastError = error;
m_lastNotice = notice;
Q_EMIT messageChanged();
}
+83
View File
@@ -0,0 +1,83 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once
#include <QObject>
#include <QQmlEngine>
#include <QUrl>
/**
* Where the database lives, as chosen by the user: the first-run dialog and
* the Preferences page drive this. The choice is stored in kareerrc
* ([Database] Path) and pushed into JobsDatabase::setConfiguredPath(); the
* --db option and KAREER_DB_PATH still win over it (see JobsDatabase::defaultPath()).
*
* Emits changed() after every switch; Main.qml reacts by refreshing the
* models, which reopen themselves via JobsDatabase::reopenIfPathChanged().
*/
class DatabaseLocation : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
/// The effective database file, or empty while the first-run choice is pending.
Q_PROPERTY(QString path READ path NOTIFY changed)
/// $XDG_DATA_HOME/kareer/kareer.sqlite.
Q_PROPERTY(QString standardPath READ standardPath CONSTANT)
Q_PROPERTY(bool setupPending READ setupPending NOTIFY changed)
/// The configured file that could not be found (so setup is being asked again), if any.
Q_PROPERTY(QString missingPath READ missingPath NOTIFY changed)
/// True when --db or KAREER_DB_PATH is in effect; the stored location is then ignored.
Q_PROPERTY(bool overridden READ overridden CONSTANT)
Q_PROPERTY(QString lastError READ lastError NOTIFY messageChanged)
Q_PROPERTY(QString lastNotice READ lastNotice NOTIFY messageChanged)
public:
explicit DatabaseLocation(QObject *parent = nullptr);
/// Reads kareerrc into JobsDatabase::setConfiguredPath(). Call once at
/// startup, for both the GUI and the CLI, so they share one database.
static void loadConfiguredPath();
/// True when the GUI should ask where the database lives: nothing forces
/// a path and the file it would open does not exist yet.
static bool needsSetup();
QString path() const;
QString standardPath() const;
bool setupPending() const;
QString missingPath() const;
bool overridden() const;
QString lastError() const;
QString lastNotice() const;
/// Create (or reuse) the database at the standard location.
Q_INVOKABLE bool useDefault();
/// Create (or reuse) kareer.sqlite inside the chosen folder.
Q_INVOKABLE bool createIn(const QUrl &folder);
/// Use an existing database file where it is.
Q_INVOKABLE bool useFile(const QUrl &file);
/// Copy the current database into the chosen folder and switch to the
/// copy. The original file is left in place.
Q_INVOKABLE bool moveTo(const QUrl &folder);
Q_INVOKABLE void clearMessages();
Q_SIGNALS:
void changed();
void messageChanged();
private:
/// Validates path, stores it (empty = standard location), and switches to it.
bool switchTo(const QString &path, bool mustExist);
void setMessages(const QString &error, const QString &notice);
QString m_missingPath;
QString m_lastError;
QString m_lastNotice;
};
+52 -5
View File
@@ -6,6 +6,8 @@
#include "jobeditmodel.h"
#include <KLocalizedString>
#include <QDate>
using namespace Qt::Literals::StringLiterals;
@@ -96,6 +98,10 @@ void JobEditModel::setJobsModel(JobsModel *model)
return;
}
m_jobsModel = model;
// QML does not guarantee editingJobId is assigned after jobsModel; if it
// came first, the resetValues() it triggered had no model to load from
// and only filled new-job defaults.
resetValues();
Q_EMIT jobsModelChanged();
}
@@ -117,6 +123,7 @@ void JobEditModel::setEditingJobId(int id)
void JobEditModel::resetValues()
{
m_values.clear();
m_loadedStage.clear();
if (m_editingJobId < 0 || !m_jobsModel) {
m_values[u"currency"_s] = u"USD"_s;
@@ -138,6 +145,7 @@ void JobEditModel::resetValues()
}
m_values[field.id] = value;
}
m_loadedStage = m_values.value(u"stage"_s).toString();
}
if (rowCount() > 0) {
@@ -170,7 +178,19 @@ void JobEditModel::setValue(int row, const QVariant &value)
bool JobEditModel::save()
{
setLastError(QString());
if (!m_jobsModel) {
setLastError(i18n("Cannot save: no applications list is attached to this form."));
return false;
}
if (m_values.value(u"company"_s).toString().trimmed().isEmpty()) {
setLastError(i18n("Company is required."));
return false;
}
if (m_values.value(u"title"_s).toString().trimmed().isEmpty()) {
setLastError(i18n("Job title is required."));
return false;
}
@@ -187,12 +207,39 @@ bool JobEditModel::save()
}
}
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();
if (m_editingJobId < 0) {
if (!m_jobsModel->addJob(fields)) {
setLastError(m_jobsModel->lastError());
return false;
}
return ok;
return true;
}
if (!m_jobsModel->updateJob(m_editingJobId, fields)) {
setLastError(m_jobsModel->lastError());
return false;
}
// updateJob() deliberately never writes the stage (so every stage change
// lands in stage_history); route a changed stage through setStage().
const QString stage = fields.value(u"stage"_s).toString();
if (!stage.isEmpty() && stage != m_loadedStage) {
if (!m_jobsModel->setStage(m_editingJobId, stage)) {
setLastError(m_jobsModel->lastError());
return false;
}
m_loadedStage = stage;
}
return true;
}
void JobEditModel::setLastError(const QString &error)
{
if (m_lastError == error) {
return;
}
m_lastError = error;
Q_EMIT lastErrorChanged();
}
bool JobEditModel::deleteJob()
+2
View File
@@ -74,10 +74,12 @@ Q_SIGNALS:
private:
void resetValues();
void setLastError(const QString &error);
JobsModel *m_jobsModel = nullptr;
int m_editingJobId = -1;
QHash<QString, QVariant> m_values;
QString m_lastError;
QString m_loadedStage; ///< Stage as loaded from the database, to detect stage changes on save.
QList<JobFieldCatalog::Field> m_fields;
};
+8 -1
View File
@@ -28,7 +28,14 @@ QList<Field> fields()
{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"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},
+1 -1
View File
@@ -6,9 +6,9 @@
#pragma once
#include <QList>
#include <QString>
#include <QStringList>
#include <QList>
/**
* The static structure of the job edit form: which categories exist, and
+116 -8
View File
@@ -22,6 +22,10 @@ namespace
{
QAtomicInteger<int> s_connectionCounter{0};
QString s_pathOverride;
QString s_configuredPath;
bool s_selectionPending = false;
QVariant salaryToVariant(int value)
{
if (value < 0) {
@@ -38,6 +42,10 @@ int salaryFromVariant(const QVariant &value)
JobsDatabase::JobsDatabase()
{
if (s_selectionPending) {
m_lastError = u"No database selected"_s;
return;
}
init(defaultPath());
}
@@ -48,6 +56,14 @@ JobsDatabase::JobsDatabase(const QString &path)
JobsDatabase::~JobsDatabase()
{
close();
}
void JobsDatabase::close()
{
if (m_connectionName.isEmpty()) {
return;
}
{
QSqlDatabase db = QSqlDatabase::database(m_connectionName, false);
if (db.isValid()) {
@@ -55,22 +71,103 @@ JobsDatabase::~JobsDatabase()
}
}
QSqlDatabase::removeDatabase(m_connectionName);
m_connectionName.clear();
m_path.clear();
}
QString JobsDatabase::defaultPath()
{
const QString overridePath = qEnvironmentVariable("KAREER_DB_PATH");
if (!overridePath.isEmpty()) {
return overridePath;
if (!s_pathOverride.isEmpty()) {
return s_pathOverride;
}
const QString dir = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + u"/kareer"_s;
QDir().mkpath(dir);
return dir + u"/kareer.sqlite"_s;
const QString envPath = qEnvironmentVariable("KAREER_DB_PATH");
if (!envPath.isEmpty()) {
return envPath;
}
if (!s_configuredPath.isEmpty()) {
return s_configuredPath;
}
return standardPath();
}
QString JobsDatabase::standardPath()
{
return QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + u"/kareer/kareer.sqlite"_s;
}
void JobsDatabase::setPathOverride(const QString &path)
{
s_pathOverride = path;
}
void JobsDatabase::setConfiguredPath(const QString &path)
{
s_configuredPath = path;
}
QString JobsDatabase::configuredPath()
{
return s_configuredPath;
}
bool JobsDatabase::hasForcedPath()
{
return !s_pathOverride.isEmpty() || !qEnvironmentVariableIsEmpty("KAREER_DB_PATH");
}
void JobsDatabase::setSelectionPending(bool pending)
{
s_selectionPending = pending;
}
bool JobsDatabase::selectionPending()
{
return s_selectionPending;
}
QString JobsDatabase::path() const
{
return m_path;
}
bool JobsDatabase::reopenIfPathChanged()
{
if (s_selectionPending) {
return false;
}
const QString wanted = defaultPath();
if (!m_path.isEmpty() && wanted == m_path) {
return false;
}
close();
m_lastError.clear();
init(wanted);
return true;
}
bool JobsDatabase::checkWritable()
{
if (!isOpen()) {
return false;
}
QSqlQuery query(QSqlDatabase::database(m_connectionName));
if (!query.exec(u"PRAGMA user_version"_s) || !query.next()) {
m_lastError = query.lastError().text();
return false;
}
const int version = query.value(0).toInt();
query.finish();
if (!query.exec(u"PRAGMA user_version = %1"_s.arg(version))) {
m_lastError = query.lastError().text();
return false;
}
return true;
}
void JobsDatabase::init(const QString &path)
{
m_connectionName = u"kareer_conn_%1"_s.arg(s_connectionCounter.fetchAndAddRelaxed(1));
m_path = path;
QDir().mkpath(QFileInfo(path).absolutePath());
@@ -191,6 +288,9 @@ Job JobsDatabase::jobFromQuery(QSqlQuery &query) const
QList<Job> JobsDatabase::allJobs() const
{
QList<Job> jobs;
if (!isOpen()) {
return jobs;
}
QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(u"SELECT * FROM jobs ORDER BY date_applied DESC, id DESC"_s);
if (!query.exec()) {
@@ -204,6 +304,9 @@ QList<Job> JobsDatabase::allJobs() const
std::optional<Job> JobsDatabase::jobById(int id) const
{
if (!isOpen()) {
return std::nullopt;
}
QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(u"SELECT * FROM jobs WHERE id = :id"_s);
query.bindValue(u":id"_s, id);
@@ -230,7 +333,8 @@ bool JobsDatabase::addJob(Job &job)
job.updatedAt = now;
QSqlQuery query(db);
query.prepare(uR"(
query.prepare(
uR"(
INSERT INTO jobs (company, title, location, remote_type, source, url, date_applied,
salary_min, salary_max, salary_expectation, currency, notes, contact,
stage, created_at, updated_at)
@@ -278,7 +382,8 @@ bool JobsDatabase::updateJob(const Job &job)
{
QSqlDatabase db = QSqlDatabase::database(m_connectionName);
QSqlQuery query(db);
query.prepare(uR"(
query.prepare(
uR"(
UPDATE jobs SET company = :company, title = :title, location = :location,
remote_type = :remote_type, source = :source, url = :url,
date_applied = :date_applied, salary_min = :salary_min,
@@ -377,6 +482,9 @@ bool JobsDatabase::deleteJob(int id)
QList<StageTransition> JobsDatabase::stageTransitions() const
{
QList<StageTransition> transitions;
if (!isOpen()) {
return transitions;
}
QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(u"SELECT job_id, from_stage, to_stage, changed_at FROM stage_history ORDER BY changed_at ASC, id ASC"_s);
if (!query.exec()) {
+36 -2
View File
@@ -31,10 +31,42 @@ public:
JobsDatabase(const JobsDatabase &) = delete;
JobsDatabase &operator=(const JobsDatabase &) = delete;
/// Default location: $XDG_DATA_HOME/kareer/kareer.sqlite, overridable
/// with the KAREER_DB_PATH environment variable (used by autotests).
/// The database file a default-constructed instance opens. Resolved in
/// order: the --db override, the KAREER_DB_PATH environment variable
/// (used by autotests), the path configured in kareerrc, and finally
/// $XDG_DATA_HOME/kareer/kareer.sqlite.
static QString defaultPath();
/// $XDG_DATA_HOME/kareer/kareer.sqlite, ignoring every override.
static QString standardPath();
/// Set from the --db command-line option; wins over everything else.
static void setPathOverride(const QString &path);
/// The user's chosen location (kareerrc); empty means standardPath().
static void setConfiguredPath(const QString &path);
static QString configuredPath();
/// True when --db or KAREER_DB_PATH decides the path, so the configured
/// location has no effect.
static bool hasForcedPath();
/// While true (GUI first run, before the user has picked a location), a
/// default-constructed instance opens nothing and reports an error.
static void setSelectionPending(bool pending);
static bool selectionPending();
/// The file this instance opened, or empty if it opened nothing.
QString path() const;
/// Reopens against defaultPath() if that no longer matches path() (the
/// user picked another location). Returns true if it reopened.
bool reopenIfPathChanged();
/// Performs a harmless write (rewrites the header's user_version), so
/// callers can tell a read-only file apart from a usable one.
bool checkWritable();
bool isOpen() const;
QString lastError() const;
@@ -59,9 +91,11 @@ public:
private:
void init(const QString &path);
void close();
bool migrate();
Job jobFromQuery(class QSqlQuery &query) const;
QString m_connectionName;
QString m_path;
QString m_lastError;
};
+1
View File
@@ -17,6 +17,7 @@ JobsModel::JobsModel(QObject *parent)
void JobsModel::refresh()
{
m_db.reopenIfPathChanged();
beginResetModel();
m_jobs = m_db.allJobs();
endResetModel();
+1 -2
View File
@@ -104,8 +104,7 @@ QColor color(const QString &stage)
bool isTerminal(const QString &stage)
{
return stage == QLatin1String("Accepted") || stage == QLatin1String("Rejected") || stage == QLatin1String("Withdrawn")
|| stage == QLatin1String("Ghosted");
return stage == QLatin1String("Accepted") || stage == QLatin1String("Rejected") || stage == QLatin1String("Withdrawn") || stage == QLatin1String("Ghosted");
}
}
+48
View File
@@ -7,6 +7,8 @@
#include "kareer-version.h"
#include "clicommands.h"
#include "databaselocation.h"
#include "jobsdatabase.h"
#include <KAboutData>
#include <KCrash>
@@ -17,6 +19,7 @@
#include <QApplication>
#include <QCommandLineParser>
#include <QCoreApplication>
#include <QFileInfo>
#include <QIcon>
#include <QQmlApplicationEngine>
#include <QQuickStyle>
@@ -63,12 +66,50 @@ static void messageHandler(QtMsgType type, const QMessageLogContext &context, co
}
}
/// Removes "--db <path>" / "--db=<path>" from argv (anywhere on the command
/// line, for both the GUI and every subcommand) and applies it as the
/// database override. Stripping it up front keeps "kareer --db x list"
/// routing to the CLI. Returns false if --db is missing its value.
static bool takeDbOption(int &argc, char **argv)
{
int out = 1;
for (int in = 1; in < argc; ++in) {
const QString arg = QString::fromLocal8Bit(argv[in]);
QString value;
if (arg == u"--db"_s) {
if (in + 1 >= argc) {
fprintf(stderr, "kareer: --db requires a path\n");
return false;
}
value = QString::fromLocal8Bit(argv[++in]);
} else if (arg.startsWith(u"--db="_s)) {
value = arg.mid(5);
} else {
argv[out++] = argv[in];
continue;
}
if (value.isEmpty()) {
fprintf(stderr, "kareer: --db requires a path\n");
return false;
}
JobsDatabase::setPathOverride(QFileInfo(value).absoluteFilePath());
}
argv[out] = nullptr;
argc = out;
return true;
}
int main(int argc, char *argv[])
{
s_defaultMessageHandler = qInstallMessageHandler(messageHandler);
if (!takeDbOption(argc, argv)) {
return 1;
}
if (argc >= 2 && Cli::isSubcommand(QString::fromLocal8Bit(argv[1]))) {
QCoreApplication app(argc, argv);
DatabaseLocation::loadConfiguredPath();
return Cli::run(app);
}
@@ -99,9 +140,16 @@ int main(int argc, char *argv[])
QCommandLineParser parser;
aboutData.setupCommandLine(&parser);
// Handled (and stripped) by takeDbOption(); declared here for --help.
parser.addOption(QCommandLineOption(u"db"_s, i18n("Use this database file instead of the configured one."), i18n("path")));
parser.process(app);
aboutData.processCommandLine(&parser);
DatabaseLocation::loadConfiguredPath();
// First run (or the configured file has gone missing): open nothing until
// the user picks a location in DatabaseSetupDialog. The CLI never waits.
JobsDatabase::setSelectionPending(DatabaseLocation::needsSetup());
QQmlApplicationEngine engine;
KLocalization::setupLocalizedContext(&engine);
+14 -8
View File
@@ -56,11 +56,10 @@ Kirigami.ScrollablePage {
Kirigami.Action {
text: i18nc("@action:button", "Save")
icon.name: "document-save"
// On failure, editModel.lastError is set and shown by errorLabel.
onTriggered: {
if (editModel.save()) {
root.done();
} else {
errorLabel.text = editModel.lastError;
}
}
}
@@ -76,6 +75,7 @@ Kirigami.ScrollablePage {
Layout.rightMargin: Kirigami.Units.largeSpacing
Layout.bottomMargin: Kirigami.Units.smallSpacing
type: Kirigami.MessageType.Error
text: editModel.lastError
visible: text.length > 0
}
@@ -204,11 +204,11 @@ Kirigami.ScrollablePage {
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)
// Live binding so values loaded after the delegate is
// created still show up. Writing back only on
// valueModified (user edits) avoids a binding loop.
value: spinD.model.value
onValueModified: editModel.setValue(spinD.sourceRow, value)
}
Kirigami.Separator {
@@ -279,7 +279,13 @@ Kirigami.ScrollablePage {
Layout.preferredHeight: Kirigami.Units.gridUnit * 5
wrapMode: TextEdit.Wrap
text: areaD.value
onTextChanged: editModel.setValue(areaD.sourceRow, text)
// TextArea has no textEdited signal; skip the echo from
// the model-driven binding and only write real edits.
onTextChanged: {
if (text !== areaD.value) {
editModel.setValue(areaD.sourceRow, text);
}
}
}
Kirigami.Separator {
+104
View File
@@ -0,0 +1,104 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
pragma ComponentBehavior: Bound
// First-run choice of where the database lives. Shown by Main.qml while
// DatabaseLocation.setupPending is true; it cannot be dismissed until a
// location has been picked, since nothing can be saved before then.
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Dialogs
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.formcard as FormCard
import io.github.toservetheking.Kareer
Kirigami.Dialog {
id: root
title: i18nc("@title:window", "Welcome to Kareer")
showCloseButton: false
closePolicy: QQC2.Popup.NoAutoClose
modal: true
preferredWidth: Kirigami.Units.gridUnit * 26
standardButtons: Kirigami.Dialog.NoButton
onOpened: DatabaseLocation.clearMessages()
ColumnLayout {
spacing: 0
QQC2.Label {
Layout.fillWidth: true
Layout.margins: Kirigami.Units.largeSpacing
wrapMode: Text.Wrap
text: DatabaseLocation.missingPath.length > 0
? xi18nc("@info", "The database at <filename>%1</filename> could not be found. Choose where Kareer should keep your applications.", DatabaseLocation.missingPath)
: i18nc("@info", "Choose where Kareer should keep your applications.")
}
Kirigami.InlineMessage {
Layout.fillWidth: true
Layout.leftMargin: Kirigami.Units.largeSpacing
Layout.rightMargin: Kirigami.Units.largeSpacing
Layout.bottomMargin: Kirigami.Units.smallSpacing
type: Kirigami.MessageType.Error
text: DatabaseLocation.lastError
visible: text.length > 0
}
FormCard.FormButtonDelegate {
Layout.fillWidth: true
icon.name: "document-new"
text: i18nc("@action:button", "Create in the default location")
description: DatabaseLocation.standardPath
onClicked: {
if (DatabaseLocation.useDefault()) {
root.close();
}
}
}
FormCard.FormButtonDelegate {
Layout.fillWidth: true
icon.name: "folder-new"
text: i18nc("@action:button", "Choose a folder…")
description: i18nc("@info", "Create a new database in a folder of your choice, for example a synced folder.")
onClicked: folderDialog.open()
}
FormCard.FormButtonDelegate {
Layout.fillWidth: true
icon.name: "document-open"
text: i18nc("@action:button", "Open an existing database…")
description: i18nc("@info", "Use a kareer.sqlite file you already have, where it is.")
onClicked: fileDialog.open()
}
}
FolderDialog {
id: folderDialog
title: i18nc("@title:window", "Choose a Folder for the Database")
onAccepted: {
if (DatabaseLocation.createIn(selectedFolder)) {
root.close();
}
}
}
FileDialog {
id: fileDialog
title: i18nc("@title:window", "Open Database")
fileMode: FileDialog.OpenFile
nameFilters: [i18nc("@item:inlistbox", "Kareer databases (*.sqlite)"), i18nc("@item:inlistbox", "All files (*)")]
onAccepted: {
if (DatabaseLocation.useFile(selectedFile)) {
root.close();
}
}
}
}
+21
View File
@@ -25,6 +25,27 @@ Kirigami.ApplicationWindow {
id: jobsModel
}
// The database moved (first-run choice or Preferences): reload the list,
// which cascades to the dashboard via countChanged, and leave any open
// edit form, since its job id belonged to the previous database.
Connections {
target: DatabaseLocation
function onChanged(): void {
jobsModel.refresh();
root.showDashboard();
}
}
DatabaseSetupDialog {
id: setupDialog
}
Component.onCompleted: {
if (DatabaseLocation.setupPending) {
setupDialog.open();
}
}
// The job currently open in the edit form, so the sidebar can keep it highlighted.
property int currentJobId: -1
+90
View File
@@ -11,6 +11,7 @@ pragma ComponentBehavior: Bound
// header, no card/border around them.
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Dialogs
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import io.github.toservetheking.Kareer
@@ -75,5 +76,94 @@ Kirigami.ScrollablePage {
displayText: AppColorScheme.activeColorSchemeName
}
}
Kirigami.ListSectionHeader {
Layout.fillWidth: true
text: i18nc("@title:group", "Database")
}
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", "Location")
}
QQC2.Label {
Layout.fillWidth: true
text: DatabaseLocation.path
wrapMode: Text.WrapAnywhere
opacity: 0.7
}
Kirigami.InlineMessage {
Layout.fillWidth: true
type: Kirigami.MessageType.Information
text: i18nc("@info", "Set by the --db option or the KAREER_DB_PATH environment variable, so it can't be changed here.")
visible: DatabaseLocation.overridden
}
Kirigami.InlineMessage {
Layout.fillWidth: true
type: Kirigami.MessageType.Error
text: DatabaseLocation.lastError
visible: text.length > 0
}
Kirigami.InlineMessage {
Layout.fillWidth: true
type: Kirigami.MessageType.Positive
text: DatabaseLocation.lastNotice
visible: text.length > 0
}
Flow {
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
enabled: !DatabaseLocation.overridden
QQC2.Button {
icon.name: "folder-move"
text: i18nc("@action:button", "Move to…")
QQC2.ToolTip.text: i18nc("@info:tooltip", "Copy the database into another folder and use the copy from now on. The current file is left in place.")
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
onClicked: moveFolderDialog.open()
}
QQC2.Button {
icon.name: "document-open"
text: i18nc("@action:button", "Open Existing…")
onClicked: openFileDialog.open()
}
QQC2.Button {
icon.name: "edit-reset"
text: i18nc("@action:button", "Use Default Location")
visible: DatabaseLocation.path !== DatabaseLocation.standardPath
QQC2.ToolTip.text: xi18nc("@info:tooltip", "Switch to <filename>%1</filename>. Nothing is copied.", DatabaseLocation.standardPath)
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
onClicked: DatabaseLocation.useDefault()
}
}
}
}
Component.onCompleted: DatabaseLocation.clearMessages()
FolderDialog {
id: moveFolderDialog
title: i18nc("@title:window", "Move Database To")
onAccepted: DatabaseLocation.moveTo(selectedFolder)
}
FileDialog {
id: openFileDialog
title: i18nc("@title:window", "Open Database")
fileMode: FileDialog.OpenFile
nameFilters: [i18nc("@item:inlistbox", "Kareer databases (*.sqlite)"), i18nc("@item:inlistbox", "All files (*)")]
onAccepted: DatabaseLocation.useFile(selectedFile)
}
}
+547 -253
View File
@@ -9,21 +9,25 @@
#include <KLocalizedString>
#include <QColor>
#include <QHash>
#include <QPair>
#include <QPointF>
#include <QSet>
#include <QVariantMap>
#include <algorithm>
#include <iterator>
#include <numeric>
#include <cmath>
#include <limits>
using namespace Qt::Literals::StringLiterals;
namespace
{
QString num(qreal v)
QString num(qreal value)
{
return QString::number(v, 'f', 2);
return QString::number(value, 'f', 2);
}
QString point(qreal x, qreal y)
@@ -31,17 +35,60 @@ QString point(qreal x, qreal y)
return num(x) + u","_s + num(y);
}
/// Cubic S-curve with horizontal tangents from the current point (xa, ya) to
/// (xb, yb). Every ribbon crossing the same gap between two columns uses the
/// same xa/xb, so two ribbons in the same vertical order at both ends of a
/// gap cannot cross inside it.
QString curveTo(qreal xa, qreal ya, qreal xb, qreal yb)
{
const qreal mid = (xa + xb) / 2.0;
return u" C"_s + point(mid, ya) + u" "_s + point(mid, yb) + u" "_s + point(xb, yb);
}
/// Outcomes other than Accepted: drop-offs end here. Accepted continues the
/// main line of the funnel instead.
bool isSink(const QString &stage)
{
return JobStage::isTerminal(stage) && stage != QLatin1String("Accepted");
}
struct NodeInfo {
QString stage;
int column = 0;
int rank = 0; ///< Index among the columns actually present.
int order = 0;
int value = 0;
bool sink = false;
qreal x = 0;
qreal y = 0;
qreal width = 0;
qreal height = 0;
};
struct LinkInfo {
QString from;
QString to;
int value = 0;
int fromIndex = 0;
int toIndex = 0;
int fromRank = 0;
int toRank = 0;
bool sink = false; ///< Ends in a drop-off outcome.
qreal thickness = 0;
qreal sourceY = 0;
qreal targetY = 0;
/// Top y of the ribbon in each column it passes through (fromRank+1 .. toRank-1).
QHash<int, qreal> laneY;
bool passesColumns() const
{
return toRank - fromRank > 1;
}
};
} // namespace
SankeyModel::SankeyModel(QObject *parent)
: QObject(parent)
@@ -65,12 +112,51 @@ bool SankeyModel::isEmpty() const
void SankeyModel::reload(qreal width, qreal height, qreal nodeWidth, qreal padding)
{
m_db.reopenIfPathChanged();
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;
// Each application is drawn once, as one left-to-right path: the funnel
// stages it reached in order, then its current stage if that is an
// outcome. Backward or sideways moves (Offer back to Interview, Ghosted
// then Rejected) are history, not flow; drawing them as ribbons would
// run right-to-left across everything else.
QList<int> jobOrder;
QHash<int, QStringList> stagesByJob;
for (const StageTransition &transition : m_db.stageTransitions()) {
const QString stage = JobStage::canonical(transition.toStage);
if (!JobStage::isValid(stage)) {
continue;
}
if (!stagesByJob.contains(transition.jobId)) {
jobOrder.append(transition.jobId);
}
stagesByJob[transition.jobId].append(stage);
}
for (int jobId : std::as_const(jobOrder)) {
const QStringList stages = stagesByJob.value(jobId);
QStringList path{QString::fromLatin1(JobStage::Start)};
int furthestColumn = JobStage::column(path.first());
for (const QString &stage : stages) {
if (JobStage::isTerminal(stage)) {
continue;
}
const int column = JobStage::column(stage);
if (column > furthestColumn) {
path.append(stage);
furthestColumn = column;
}
}
if (!stages.isEmpty() && JobStage::isTerminal(stages.last())) {
path.append(stages.last());
}
for (int i = 0; i + 1 < path.size(); ++i) {
++m_counts[{path.at(i), path.at(i + 1)}];
}
}
relayout(width, height, nodeWidth, padding);
}
@@ -79,156 +165,85 @@ void SankeyModel::relayout(qreal width, qreal height, qreal nodeWidth, qreal pad
m_nodes.clear();
m_links.clear();
if (width <= 0 || height <= 0 || m_counts.isEmpty()) {
if (width <= 0 || height <= 0 || nodeWidth <= 0 || m_counts.isEmpty()) {
Q_EMIT changed();
return;
}
padding = qMax<qreal>(0.0, padding);
//
// Graph. Only left-to-right links are laid out; reload() never produces
// anything else, but stay safe against arbitrary counts.
//
QHash<QString, int> inbound;
QHash<QString, int> outbound;
QSet<QString> stageNames;
QList<QPair<QString, QString>> linkKeys;
for (auto it = m_counts.constBegin(); it != m_counts.constEnd(); ++it) {
const QString &from = it.key().first;
const QString &to = it.key().second;
if (it.value() <= 0 || from.isEmpty() || to.isEmpty() || JobStage::column(to) <= JobStage::column(from)) {
continue;
}
outbound[from] += it.value();
inbound[to] += it.value();
stageNames.insert(from);
stageNames.insert(to);
linkKeys.append(it.key());
}
if (stageNames.isEmpty()) {
Q_EMIT changed();
return;
}
//
// Nodes and the columns actually present.
//
QList<NodeInfo> nodeList;
nodeList.reserve(stageNames.size());
for (const QString &stage : std::as_const(stageNames)) {
NodeInfo n;
n.stage = stage;
n.column = JobStage::column(stage);
n.order = JobStage::orderInColumn(stage);
n.value = qMax(inbound.value(stage, 0), outbound.value(stage, 0));
nodeList.append(n);
NodeInfo node;
node.stage = stage;
node.column = JobStage::column(stage);
node.order = JobStage::orderInColumn(stage);
node.sink = isSink(stage);
// Applications still sitting in a stage make its inflow exceed its
// outflow; the node must be tall enough for the larger side.
node.value = qMax(inbound.value(stage, 0), outbound.value(stage, 0));
nodeList.append(node);
}
std::sort(nodeList.begin(), nodeList.end(), [](const NodeInfo &a, const NodeInfo &b) {
if (a.column != b.column) {
return a.column < b.column;
}
if (a.order != b.order) {
return a.order < b.order;
}
return a.stage < b.stage;
});
QHash<int, QList<int>> columnIndices;
for (int i = 0; i < nodeList.size(); ++i) {
columnIndices[nodeList.at(i).column].append(i);
QList<int> usedColumns;
for (const NodeInfo &node : std::as_const(nodeList)) {
if (!usedColumns.contains(node.column)) {
usedColumns.append(node.column);
}
}
// Columns are spread by their rank among the columns actually present,
// 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
// largest total flow determines it, so no column can overflow the
// available height.
qreal scale = 1.0;
bool haveScale = false;
for (auto it = columnIndices.constBegin(); it != columnIndices.constEnd(); ++it) {
int total = 0;
for (int idx : it.value()) {
total += nodeList.at(idx).value;
}
if (total <= 0) {
continue;
}
const qreal gaps = padding * qMax(0, it.value().size() - 1);
const qreal available = qMax<qreal>(1.0, height - gaps);
const qreal candidate = available / total;
if (!haveScale || candidate < scale) {
scale = candidate;
haveScale = true;
}
}
if (!haveScale) {
scale = 1.0;
}
// 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) {
const QList<int> &idxs = it.value();
const qreal minHeight = minHeightByColumn.value(it.key());
// Columns are top-aligned rather than centered: the funnel's success
// path then runs level along the top while drop-off ribbons peel
// downward into the open space beneath it, instead of every column
// being centered and the ribbons weaving up and down to meet.
const qreal startY = 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;
for (int idx : idxs) {
NodeInfo &n = nodeList[idx];
n.x = x;
n.y = cursorY;
n.width = nodeWidth;
n.height = qMax(n.value * scale, minHeight);
cursorY += n.height + padding;
}
}
QHash<QString, int> nodeIndexByStage;
for (int i = 0; i < nodeList.size(); ++i) {
nodeList[i].rank = usedColumns.indexOf(nodeList.at(i).column);
nodeIndexByStage.insert(nodeList.at(i).stage, i);
}
QList<QPair<QString, QString>> linkKeys = m_counts.keys();
//
// Links.
//
std::sort(linkKeys.begin(), linkKeys.end(), [&](const QPair<QString, QString> &a, const QPair<QString, QString> &b) {
const int aFrom = nodeIndexByStage.value(a.first);
const int bFrom = nodeIndexByStage.value(b.first);
@@ -238,177 +253,456 @@ void SankeyModel::relayout(qreal width, qreal height, qreal nodeWidth, qreal pad
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;
QList<LinkInfo> links;
links.reserve(linkKeys.size());
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;
LinkInfo link;
link.from = key.first;
link.to = key.second;
link.value = m_counts.value(key);
link.fromIndex = nodeIndexByStage.value(key.first);
link.toIndex = nodeIndexByStage.value(key.second);
link.fromRank = nodeList.at(link.fromIndex).rank;
link.toRank = nodeList.at(link.toIndex).rank;
link.sink = nodeList.at(link.toIndex).sink;
links.append(link);
}
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));
//
// Order the drop-off outcomes top to bottom by where their applications
// dropped off: outcomes fed by later stages sit higher. Drop-off ribbons
// travel in lanes ordered the same way (latest stage innermost), so this
// keeps the ribbons' order when they rejoin as close as possible to the
// order they travelled in.
//
QHash<int, qreal> meanSourceRank;
{
QHash<int, qreal> weighted;
QHash<int, int> total;
for (const LinkInfo &link : std::as_const(links)) {
if (link.sink) {
weighted[link.toIndex] += static_cast<qreal>(link.value) * link.fromRank;
total[link.toIndex] += link.value;
}
}
for (auto it = total.constBegin(); it != total.constEnd(); ++it) {
meanSourceRank.insert(it.key(), weighted.value(it.key()) / it.value());
}
}
QList<int> sinkOrder;
for (int i = 0; i < nodeList.size(); ++i) {
if (nodeList.at(i).sink) {
sinkOrder.append(i);
}
}
std::sort(sinkOrder.begin(), sinkOrder.end(), [&](int a, int b) {
const qreal ma = meanSourceRank.value(a);
const qreal mb = meanSourceRank.value(b);
if (!qFuzzyCompare(ma + 1.0, mb + 1.0)) {
return ma > mb;
}
return a < b; // nodeList is already sorted by JobStage::orderInColumn().
});
QHash<int, int> sinkPosition;
for (int i = 0; i < sinkOrder.size(); ++i) {
sinkPosition.insert(sinkOrder.at(i), i);
}
// 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;
//
// What each column holds, top to bottom:
//
// over-lanes main-line links skipping this column (e.g. Applied -> Interview)
// spine node the funnel stage (or Accepted)
// under-lanes drop-off links on their way to an outcome
// sinks the drop-off outcomes themselves (last column only)
//
// Lanes keep one order along their whole run, and a stage's drop-offs join
// the under-lanes on top (innermost), so drop-offs peel off each stage and
// nest around each other instead of braiding.
//
QList<QList<int>> overLanes(rankCount);
QList<QList<int>> underLanes(rankCount);
QList<QList<int>> spineNodes(rankCount);
QList<QList<int>> sinkNodes(rankCount);
for (int i = 0; i < links.size(); ++i) {
const LinkInfo &link = links.at(i);
for (int rank = link.fromRank + 1; rank < link.toRank; ++rank) {
(link.sink ? underLanes : overLanes)[rank].append(i);
}
}
for (int rank = 0; rank < rankCount; ++rank) {
std::sort(overLanes[rank].begin(), overLanes[rank].end(), [&](int a, int b) {
const LinkInfo &la = links.at(a);
const LinkInfo &lb = links.at(b);
if (la.fromRank != lb.fromRank) {
return la.fromRank < lb.fromRank;
}
if (la.toRank != lb.toRank) {
return la.toRank > lb.toRank;
}
return a < b;
});
std::sort(underLanes[rank].begin(), underLanes[rank].end(), [&](int a, int b) {
const LinkInfo &la = links.at(a);
const LinkInfo &lb = links.at(b);
if (la.fromRank != lb.fromRank) {
return la.fromRank > lb.fromRank;
}
if (sinkPosition.value(la.toIndex) != sinkPosition.value(lb.toIndex)) {
return sinkPosition.value(la.toIndex) < sinkPosition.value(lb.toIndex);
}
return a < b;
});
}
for (int i = 0; i < nodeList.size(); ++i) {
if (!nodeList.at(i).sink) {
spineNodes[nodeList.at(i).rank].append(i);
}
}
for (int index : std::as_const(sinkOrder)) {
sinkNodes[nodeList.at(index).rank].append(index);
}
//
// Shared scale: the largest for which every column's stack fits.
//
// Link thickness is always exactly value * scale. Nodes get a small
// visual minimum height, which never changes link widths.
//
constexpr qreal minimumNodeHeight = 2.0;
const auto laneThickness = [&](const QList<int> &lanes, qreal scale) {
qreal sum = 0;
for (int index : lanes) {
sum += links.at(index).value * scale;
}
return sum;
};
const auto blockCount = [&](int rank) {
return static_cast<int>(!overLanes.at(rank).isEmpty()) + static_cast<int>(!underLanes.at(rank).isEmpty()) + spineNodes.at(rank).size()
+ sinkNodes.at(rank).size();
};
QList<qreal> minimumHeight(rankCount, 0.0);
for (int rank = 0; rank < rankCount; ++rank) {
const int nodeCount = spineNodes.at(rank).size() + sinkNodes.at(rank).size();
if (nodeCount > 0) {
const qreal available = qMax<qreal>(0.0, height - padding * qMax(0, blockCount(rank) - 1));
minimumHeight[rank] = qMin(minimumNodeHeight, available / nodeCount);
}
}
const auto stackHeight = [&](int rank, qreal scale) {
qreal used = padding * qMax(0, blockCount(rank) - 1);
used += laneThickness(overLanes.at(rank), scale) + laneThickness(underLanes.at(rank), scale);
for (const QList<int> *group : {&spineNodes.at(rank), &sinkNodes.at(rank)}) {
for (int index : *group) {
used += qMax(minimumHeight.at(rank), nodeList.at(index).value * scale);
}
}
return used;
};
const auto fits = [&](qreal scale) {
for (int rank = 0; rank < rankCount; ++rank) {
if (stackHeight(rank, scale) > height + 0.0001) {
return false;
}
}
return true;
};
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);
qreal scale = 0.0;
if (fits(0.0)) {
qreal low = 0.0;
qreal high = 0.0;
for (const NodeInfo &node : std::as_const(nodeList)) {
if (node.value > 0) {
high = qMax(high, height / static_cast<qreal>(node.value));
}
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);
for (int iteration = 0; iteration < 60; ++iteration) {
const qreal middle = (low + high) / 2.0;
if (fits(middle)) {
low = middle;
} else {
high = middle;
}
}
scale = low;
}
for (LinkInfo &link : links) {
link.thickness = link.value * scale;
}
for (int i = 0; i < nodeList.size(); ++i) {
NodeInfo &node = nodeList[i];
node.width = nodeWidth;
node.height = qMax(minimumHeight.at(node.rank), node.value * scale);
}
//
// Positions. Columns spread over the width by rank; every stack starts at
// the same top, and the tallest one is centered vertically.
//
qreal contentHeight = 0;
for (int rank = 0; rank < rankCount; ++rank) {
contentHeight = qMax(contentHeight, stackHeight(rank, scale));
}
const qreal top = qMax<qreal>(0.0, (height - contentHeight) / 2.0);
const qreal bottom = top + contentHeight;
QList<qreal> columnX(rankCount);
for (int rank = 0; rank < rankCount; ++rank) {
columnX[rank] = rankCount > 1 ? rank * (width - nodeWidth) / static_cast<qreal>(rankCount - 1) : (width - nodeWidth) / 2.0;
}
for (NodeInfo &node : nodeList) {
node.x = columnX.at(node.rank);
}
//
// Where each ribbon leaves its source, top to bottom: links skipping
// ahead over later stages, the link to the next stage, then drop-offs.
// Applications still sitting in the stage leave the bottom of the node
// empty. A column has at most one main-line node, so the next stage's
// position never needs to break a tie here.
//
QList<QList<int>> outgoing(nodeList.size());
QList<QList<int>> incoming(nodeList.size());
for (int i = 0; i < links.size(); ++i) {
outgoing[links.at(i).fromIndex].append(i);
incoming[links.at(i).toIndex].append(i);
}
const auto outCategory = [&](const LinkInfo &link) {
if (link.sink) {
return 2;
}
return link.passesColumns() ? 0 : 1;
};
const auto assignOutSlots = [&](int n) {
QList<int> &slotOrder = outgoing[n];
std::sort(slotOrder.begin(), slotOrder.end(), [&](int a, int b) {
const LinkInfo &la = links.at(a);
const LinkInfo &lb = links.at(b);
const int ca = outCategory(la);
const int cb = outCategory(lb);
if (ca != cb) {
return ca < cb;
}
if (ca == 0 && la.toRank != lb.toRank) {
return la.toRank > lb.toRank;
}
if (ca == 2 && sinkPosition.value(la.toIndex) != sinkPosition.value(lb.toIndex)) {
return sinkPosition.value(la.toIndex) < sinkPosition.value(lb.toIndex);
}
return a < b;
});
QHash<QString, qreal> sourceCursor;
for (const NodeInfo &n : std::as_const(nodeList)) {
sourceCursor.insert(n.stage, n.y);
qreal cursor = nodeList.at(n).y;
for (int index : std::as_const(slotOrder)) {
links[index].sourceY = cursor;
cursor += links.at(index).thickness;
}
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> sinkCursor(rankCount, top);
for (int rank = 0; rank < rankCount; ++rank) {
qreal cursor = top;
bool first = true;
const auto startBlock = [&]() {
if (!first) {
cursor += padding;
}
first = false;
};
if (!overLanes.at(rank).isEmpty()) {
startBlock();
for (int index : overLanes.at(rank)) {
links[index].laneY.insert(rank, cursor);
cursor += links.at(index).thickness;
}
}
for (int index : spineNodes.at(rank)) {
startBlock();
nodeList[index].y = cursor;
cursor += nodeList.at(index).height;
assignOutSlots(index);
}
if (!underLanes.at(rank).isEmpty()) {
// Drop-off lanes stay level instead of rising whenever the stage
// above them gets shorter: each lane sits no higher than it was in
// the previous column (or where it left its stage), and only moves
// down to make room. They still keep their order, and are pulled
// back up only as far as needed to stay inside the diagram.
startBlock();
const QList<int> &lanes = underLanes.at(rank);
for (int index : lanes) {
LinkInfo &link = links[index];
const qreal previous = link.fromRank == rank - 1 ? link.sourceY : link.laneY.value(rank - 1);
const qreal y = qMax(cursor, previous);
link.laneY.insert(rank, y);
cursor = y + link.thickness;
}
qreal limit = bottom;
for (auto it = lanes.crbegin(); it != lanes.crend(); ++it) {
LinkInfo &link = links[*it];
const qreal y = qMin(link.laneY.value(rank), limit - link.thickness);
link.laneY.insert(rank, y);
limit = y;
}
cursor = links.at(lanes.last()).laneY.value(rank) + links.at(lanes.last()).thickness;
}
sinkCursor[rank] = first ? cursor : cursor + padding;
}
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);
//
// Drop-off outcomes: stacked in the last column, as close as possible to
// the height their ribbons arrive at, so rejoining stays shallow.
//
for (int rank = 0; rank < rankCount; ++rank) {
const QList<int> &sinks = sinkNodes.at(rank);
if (sinks.isEmpty()) {
continue;
}
const qreal ya = nodeCenter(ka.first);
const qreal yb = nodeCenter(kb.first);
if (!qFuzzyCompare(ya, yb)) {
return ya < yb;
qreal arrival = std::numeric_limits<qreal>::max();
for (int n : sinks) {
for (int index : std::as_const(incoming.at(n))) {
const LinkInfo &link = links.at(index);
arrival = qMin(arrival, link.passesColumns() ? link.laneY.value(link.toRank - 1) : link.sourceY);
}
return nodeIndexByStage.value(ka.first) < nodeIndexByStage.value(kb.first);
}
qreal groupHeight = padding * (sinks.size() - 1);
for (int n : sinks) {
groupHeight += nodeList.at(n).height;
}
const qreal lowest = sinkCursor.at(rank);
const qreal highest = qMax(lowest, bottom - groupHeight);
qreal cursor = qBound(lowest, arrival, highest);
for (int n : sinks) {
nodeList[n].y = cursor;
cursor += nodeList.at(n).height + padding;
}
}
//
// Where each ribbon enters its target, top to bottom: for a stage, links
// arriving over earlier stages first, then the link from the previous
// stage; for a drop-off outcome, the latest stage first, matching the
// lane order the ribbons arrive in.
//
for (int n = 0; n < nodeList.size(); ++n) {
QList<int> &slotOrder = incoming[n];
const bool sink = nodeList.at(n).sink;
std::sort(slotOrder.begin(), slotOrder.end(), [&](int a, int b) {
const LinkInfo &la = links.at(a);
const LinkInfo &lb = links.at(b);
if (sink) {
if (la.fromRank != lb.fromRank) {
return la.fromRank > lb.fromRank;
}
return a < b;
}
if (la.passesColumns() != lb.passesColumns()) {
return la.passesColumns();
}
if (la.fromRank != lb.fromRank) {
return la.fromRank < lb.fromRank;
}
return a < b;
});
QHash<QString, qreal> targetCursor;
for (const NodeInfo &n : std::as_const(nodeList)) {
targetCursor.insert(n.stage, n.y);
qreal cursor = nodeList.at(n).y;
for (int index : std::as_const(slotOrder)) {
links[index].targetY = cursor;
cursor += links.at(index).thickness;
}
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 &to = key.second;
const int value = m_counts.value(key);
//
// Ribbon paths: an S-curve through each gap between columns and a straight
// run across every column the ribbon passes.
//
for (const LinkInfo &link : std::as_const(links)) {
if (link.thickness <= 0.0) {
continue;
}
const NodeInfo &fromNode = nodeList.at(link.fromIndex);
const NodeInfo &toNode = nodeList.at(link.toIndex);
const NodeInfo &fromNode = nodeList.at(nodeIndexByStage.value(from));
const NodeInfo &toNode = nodeList.at(nodeIndexByStage.value(to));
// Top edge, left to right: (x, y) at each column boundary.
QList<QPointF> edge;
edge.append({fromNode.x + fromNode.width, link.sourceY});
for (int rank = link.fromRank + 1; rank < link.toRank; ++rank) {
const qreal y = link.laneY.value(rank);
edge.append({columnX.at(rank), y});
edge.append({columnX.at(rank) + nodeWidth, y});
}
edge.append({toNode.x, link.targetY});
const qreal thickness = finalThickness.at(i);
const qreal y0Top = sourceYTop.at(i);
const qreal y0Bottom = y0Top + thickness;
const qreal y1Top = targetYTop.at(i);
const qreal y1Bottom = y1Top + thickness;
QString path = u"M"_s + point(edge.first().x(), edge.first().y());
for (int i = 1; i < edge.size(); ++i) {
const QPointF &a = edge.at(i - 1);
const QPointF &b = edge.at(i);
// Odd steps cross a gap; even steps run straight across a column.
path += i % 2 == 1 ? curveTo(a.x(), a.y(), b.x(), b.y()) : u" L"_s + point(b.x(), b.y());
}
path += u" L"_s + point(edge.last().x(), edge.last().y() + link.thickness);
for (int i = edge.size() - 1; i > 0; --i) {
const QPointF &a = edge.at(i);
const QPointF &b = edge.at(i - 1);
path += i % 2 == 1 ? curveTo(a.x(), a.y() + link.thickness, b.x(), b.y() + link.thickness) : u" L"_s + point(b.x(), b.y() + link.thickness);
}
path += u" Z"_s;
const qreal x0 = fromNode.x + fromNode.width;
const qreal x1 = toNode.x;
const qreal midX = (x0 + x1) / 2.0;
const QString path = u"M"_s + point(x0, y0Top) + u" C"_s + point(midX, y0Top) + u" "_s + point(midX, y1Top) + u" "_s + point(x1, y1Top)
+ u" L"_s + point(x1, y1Bottom) + u" C"_s + point(midX, y1Bottom) + u" "_s + point(midX, y0Bottom) + u" "_s + point(x0, y0Bottom) + u" Z"_s;
QColor linkColor = JobStage::color(from);
linkColor.setAlphaF(0.5f);
QColor linkColor = JobStage::color(link.from);
linkColor.setAlphaF(0.5);
m_links.append(QVariantMap{
{u"fromStage"_s, from},
{u"toStage"_s, to},
{u"value"_s, value},
{u"fromStage"_s, link.from},
{u"toStage"_s, link.to},
{u"value"_s, link.value},
{u"pathData"_s, path},
{u"color"_s, linkColor},
{u"thickness"_s, thickness},
{u"sourceY"_s, y0Top},
{u"targetY"_s, y1Top},
{u"thickness"_s, link.thickness},
{u"sourceY"_s, link.sourceY},
{u"targetY"_s, link.targetY},
});
}
// Labels sit to the right of each node except in the last used column,
// whose labels go to the left (d3-sankey style) so nothing ever renders
// past the right edge. Each label is capped to the gap before the next
// column in use, and the final gap is split between the right-side label
// of the penultimate column and the left-side label of the last one, so
// text elides instead of overlapping.
QList<qreal> columnStarts;
for (const NodeInfo &n : std::as_const(nodeList)) {
if (!columnStarts.contains(n.x)) {
columnStarts.append(n.x);
}
}
std::sort(columnStarts.begin(), columnStarts.end());
//
// Nodes and their labels. Labels go right of every column but the last,
// whose labels go left.
//
constexpr qreal labelMargin = 8.0;
constexpr qreal minLabelWidth = 24.0;
const int lastRank = rankCount - 1;
for (const NodeInfo &n : std::as_const(nodeList)) {
const QString label = n.stage == QLatin1String(JobStage::Start) ? i18n("Applications") : i18n(n.stage.toUtf8().constData());
for (const NodeInfo &node : std::as_const(nodeList)) {
const QString label = node.stage == QLatin1String(JobStage::Start) ? i18n("Applications") : i18n(node.stage.toUtf8().constData());
const int columnPos = static_cast<int>(std::distance(columnStarts.begin(), std::find(columnStarts.begin(), columnStarts.end(), n.x)));
const int lastPos = columnStarts.size() - 1;
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;
const bool labelOnRight = node.rank < lastRank || lastRank == 0;
qreal labelWidth = 0.0;
if (lastRank == 0) {
labelWidth = width - (node.x + node.width) - labelMargin;
} else if (node.rank < lastRank) {
const qreal gap = columnX.at(node.rank + 1) - (node.x + node.width);
// The last gap is shared with the last column's labels, which
// are drawn on its left.
const qreal share = node.rank == lastRank - 1 ? 0.4 : 1.0;
labelWidth = gap * share - 2.0 * labelMargin;
} else {
const qreal gap = n.x - (columnStarts.at(columnPos - 1) + n.width);
labelWidth = gap * 0.6 - 2 * labelMargin;
const qreal gap = node.x - (columnX.at(node.rank - 1) + node.width);
labelWidth = gap * 0.6 - 2.0 * labelMargin;
}
labelWidth = qMax(labelWidth, minLabelWidth);
m_nodes.append(QVariantMap{
{u"stage"_s, n.stage},
{u"stage"_s, node.stage},
{u"label"_s, label},
{u"x"_s, n.x},
{u"y"_s, n.y},
{u"width"_s, n.width},
{u"height"_s, n.height},
{u"value"_s, n.value},
{u"color"_s, JobStage::color(n.stage)},
{u"x"_s, node.x},
{u"y"_s, node.y},
{u"width"_s, node.width},
{u"height"_s, node.height},
{u"value"_s, node.value},
{u"color"_s, JobStage::color(node.stage)},
{u"labelWidth"_s, labelWidth},
{u"labelOnRight"_s, labelOnRight},
});
+1
View File
@@ -17,6 +17,7 @@ StatsModel::StatsModel(QObject *parent)
void StatsModel::refresh()
{
m_db.reopenIfPathChanged();
m_jobs = m_db.allJobs();
Q_EMIT changed();
}