15 Commits
Author SHA1 Message Date
austinandClaude Opus 5 f304850c7a v0.1.6: ship 0.1.5's features with the icon fix
Lint / REUSE compliance (push) Successful in 3s
Build and Test / Build and run tests (push) Failing after 1s
Lint / clang-format (push) Failing after 8s
Build and Publish Flatpak / Build Flatpak (push) Failing after 4s
Build and Publish Flatpak / Deploy hosted repo to Pages (push) Skipped
v0.1.5 was tagged but never published: its Flatpak build failed on the
new icon. This release carries the same features (editable stage
history, `kareer history`, auto-ghosting, new icon, Add-form fix) plus
the icon fix. Bump the version, add the 0.1.6 metainfo release, and
update the manifest's pinned tag.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01BxDf7HqD1xPnsZP8wt3NTk
2026-09-11 17:09:10 -05:00
austinandClaude Opus 5 ea4a3cc315 Fix the Flatpak build: keep the icon's <svg> tag in its first 256 bytes
AppStream compose in the Flatpak build failed with "Unrecognized image
file format" for the new scalable icon, so v0.1.5 was never published.
Image loaders recognise an SVG by finding "<svg" within the first 256
bytes, and the icon's descriptive header comment pushed the tag past
that. The comment now lives inside the element; the icon renders
pixel-identically. icons/CMakeLists.txt now fails configuration if the
tag drifts past 256 bytes again. Verified with the KDE 6.10 runtime's
appstreamcli-compose, which now succeeds and emits all catalog icons.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01BxDf7HqD1xPnsZP8wt3NTk
2026-09-11 17:09:08 -05:00
austinandClaude Opus 5 9b57fc7bcf dist/arch: update PKGBUILD to 0.1.5
Lint / REUSE compliance (push) Successful in 3s
Lint / clang-format (push) Failing after 4s
Build and Test / Build and run tests (push) Failing after 2s
sha256 of GitHub's v0.1.5 archive, verified stable across two downloads.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01BxDf7HqD1xPnsZP8wt3NTk
2026-09-11 17:02:19 -05:00
austinandClaude Opus 5 fc9928aef8 v0.1.5: editable stage history, auto-ghosting, new icon
Lint / REUSE compliance (push) Successful in 3s
Lint / clang-format (push) Failing after 4s
Build and Test / Build and run tests (push) Failing after 2s
Build and Publish Flatpak / Build Flatpak (push) Failing after 2s
Build and Publish Flatpak / Deploy hosted repo to Pages (push) Skipped
- Edit an application's full stage history, with a date per step, so an
  application logged after the fact keeps its whole path
- `kareer history` to show or replace a stage history
- Mark applications with no response as Ghosted after 30 days,
  adjustable or off in Preferences
- New app icon, installed under the app ID so launchers and the Flatpak
  show it
- Fix the Add Application form filling empty fields with "undefined"
- Bump the version, add the 0.1.5 metainfo release, and update the
  manifest's pinned tag

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01BxDf7HqD1xPnsZP8wt3NTk
2026-09-11 17:01:38 -05:00
austinandClaude Opus 5 eb47a589cb Editable stage history, auto-ghosting, and an Add-form fix
Stage history editor:
- The edit form's Pipeline section is now the application's history:
  one row per step (stage + date), Add Step and Remove Step, kept in
  date order. The last step is the current stage and the first step's
  date the date applied, replacing the separate Stage and Date Applied
  fields. An application logged after the fact (applied, interviewed,
  rejected) keeps its whole path.
- JobsDatabase::replaceStageHistory() rewrites a job's history in one
  transaction (sorted, repeats collapsed, stage and date applied kept in
  step); StageHistoryModel backs the section; JobsModel::addJob() now
  returns the new id so a new application's history can be saved.
- `kareer history <id> [Stage=YYYY-MM-DD ...]` shows or replaces it.

Auto-ghosting:
- Applications still at Applied with no activity (the later of the
  date applied and the last stage change) for more than 30 days move to
  Ghosted, recorded like any stage change. Runs at startup for GUI and
  CLI, after switching databases, and shortly after the setting changes.
- Preferences gains an Applications section: on/off and the number of
  days (kareerrc [AutoGhost]). The GUI shows a passive notification; the
  CLI notes it on stderr so --json output stays clean.

Fixes:
- The Add Application form pre-filled empty text fields with the word
  "undefined" (typing "a" gave "undefineda"): fields with no value
  reached QML as undefined. Every field now gets a typed default.
- Embed the app icon as the window icon fallback, so the window and
  About page show it when running uninstalled.

Tests: history replacement and the after-the-fact Allstate case, the
ghosting rules (fresh/stale/logged-late/reopened/threshold), and empty
new-form fields.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01BxDf7HqD1xPnsZP8wt3NTk
2026-09-11 17:00:45 -05:00
austinandClaude Opus 5 289246a6d0 New app icon: a K drawn as the pipeline splitting
The stem is every application, splitting into a green success path
rising to an outcome and a drop-off falling away, on a Breeze-blue
rounded square. Plain shapes and gradients only, so QtSvg renders it
exactly; PNGs from 16 to 128 px are rendered from the SVG.

The icon set is now named after the app ID (io.github.toservetheking.
Kareer). It was installed as "kareer", which nothing looked up: the
desktop file and the window ask for the app ID, so launchers fell back
to a generic icon, and Flatpak (which only exports icons prefixed with
the app ID) shipped none.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01BxDf7HqD1xPnsZP8wt3NTk
2026-09-11 17:00:30 -05:00
austinandClaude Opus 5 1f1c628ee4 dist/arch: update PKGBUILD to 0.1.4
Lint / REUSE compliance (push) Successful in 3s
Build and Test / Build and run tests (push) Failing after 1s
Lint / clang-format (push) Failing after 4s
sha256 of GitHub's v0.1.4 archive, verified stable across two downloads.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01BxDf7HqD1xPnsZP8wt3NTk
2026-09-11 16:18:33 -05:00
austinandClaude Opus 5 76c6e05ee0 v0.1.4: fix Save, un-braid the pipeline, choose the database location
Lint / REUSE compliance (push) Successful in 3s
Lint / clang-format (push) Failing after 5s
Build and Test / Build and run tests (push) Failing after 2s
Build and Publish Flatpak / Build Flatpak (push) Failing after 3s
Build and Publish Flatpak / Deploy hosted repo to Pages (push) Skipped
- Fix Save on the edit form silently dropping stage changes; report a
  missing company or title instead of saving a blank field
- Rework the pipeline Sankey: drop-offs split off each stage in level
  lanes and rejoin their outcome without braiding
- Count each application once as a forward path, so back moves and
  ghosted-then-rejected no longer draw extra ribbons
- First-run choice of database location (default, a folder, or an
  existing file used in place), plus a Database section in Preferences
- --db <path> for the GUI and every subcommand
- Bump the version, add the 0.1.4 metainfo release, and update the
  manifest's pinned tag

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01BxDf7HqD1xPnsZP8wt3NTk
2026-09-11 16:14:35 -05:00
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
60 changed files with 3457 additions and 398 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
+65
View File
@@ -0,0 +1,65 @@
# 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 go through the history.** `updateJob` deliberately does not write the stage column. `addJob` records a synthetic `Start -> stage` transition, `setStage` records a single move, and `replaceStageHistory` rewrites a job's whole history in one transaction (sorted by time, consecutive repeats collapsed; it also sets `jobs.stage` to the last step and `date_applied` to the first step's date). That history is the sole input to the Sankey diagram, so never write `jobs.stage` any other way. The GUI edits history rather than a stage field: `StageHistoryModel` backs the edit form's Pipeline section, and `JobEditModel::save()` derives stage/date applied from it and calls `replaceStageHistory` for new jobs and for edited histories. `kareer history <id> [Stage=YYYY-MM-DD ...]` is the CLI equivalent.
**Auto-ghosting.** `JobsDatabase::ghostStaleApplications(days)` moves jobs still at Applied whose last activity (the later of `date_applied` and their latest `stage_history` entry) is more than `days` old to Ghosted via `setStage`, so it is recorded like any move. `AutoGhost` (QML singleton; `kareerrc` `[AutoGhost] Enabled/Days`, default on/30) runs it at startup for GUI and CLI in `main.cpp`, again from `Main.qml` after a database switch, and ~1.5 s after the Preferences setting settles; `ran(count)` makes `Main.qml` refresh and show a passive notification.
**Stage vocabulary.** `JobStage` (`jobstage.h`) is a closed, fixed list (Applied, Screening, Interview, Onsite, Offer, Accepted, Rejected, Withdrawn, Ghosted, plus the synthetic `Start`). It also owns each stage's Sankey column, in-column stacking order, color, and terminal flag. Input is canonicalized case-insensitively on write. Adding a stage touches this file, the migration's canonicalization loop, and the README.
**Sankey layout.** `SankeyModel::reload()` turns `stage_history` into one left-to-right path per job (Start, the funnel stages it reached in increasing column order, then its current stage if terminal), so backward/sideways moves never become ribbons and node values equal the number of jobs that reached them. `relayout()` computes all geometry, including SVG path strings for `QtQuick.Shapes` `PathSvg`; `SankeyDiagram.qml` only draws the `nodes`/`links` lists. Layout is lane-based: links to Rejected/Withdrawn/Ghosted are drop-offs that travel in under-lanes below each column's node; main-line links that skip a column travel in over-lanes above it; each column stack is top-aligned and the whole block is centered. Every ribbon crossing a given gap between columns uses the same x endpoints, so ribbons can only cross if their vertical order differs at the two ends of a gap; the lane/slot orderings are chosen to keep it equal everywhere except the final gap into the outcome nodes. `reload()` re-reads the DB; `relayout()` recomputes geometry from cached counts (used on resize via a debounce timer). `autotests/sankeylayouttest.cpp` checks this on the drawn geometry (it parses `pathData` and asserts ribbons never overlap) and points `KAREER_DB_PATH` at a temp file because `SankeyModel` always opens the default path.
**Edit form.** The add/edit form is data-driven rather than hand-written per field: `JobFieldCatalog` is the static list of categories and fields (id, label, row type, combo options, spin range); `JobEditModel` is a `QAbstractListModel` with one row per field holding the current values, and `ApplicationEditPage.qml` renders it with a `DelegateChooser` on `rowType`. Field ids must match the keys `JobsModel::mapFromJob` / `jobFromMap` use. The `pipeline` category deliberately has no catalog fields; `ApplicationEditPage.qml` renders `JobEditModel::history` there instead. Adding a form field means: `Job` struct, `JobsDatabase` columns and `jobFromQuery`, `JobsModel` roles and map conversion, `JobFieldCatalog`, and the CLI options.
**Window layout.** `Main.qml` uses a two-column `pageStack`: `ApplicationsPage` (sidebar list) is fixed, and the second column is swapped between `DashboardPage` and `ApplicationEditPage` with `pageStack.replace`. A single `JobsModel` instance is created in `Main.qml` and passed down as a property.
**App icon.** `icons/` holds the hicolor set named after the app ID (`sc-apps-io.github.toservetheking.Kareer.svg` plus PNGs rendered from it); the desktop file, `setDesktopFileName`, and Flatpak's icon export all depend on that exact name. The SVG is also embedded (`qt_add_resources` in `src/CMakeLists.txt`) as the window-icon fallback for uninstalled runs. Keep the SVG to plain shapes and gradients: QtSvg ignores filters.
## Conventions
- Every source file starts with an SPDX header (`GPL-3.0-or-later` for code; docs like README/CONTRIBUTING are CC0). Files that cannot carry one are listed in `REUSE.toml`. CI fails on missing headers.
- 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.
+5 -1
View File
@@ -5,7 +5,7 @@
cmake_minimum_required(VERSION 3.16)
project(kareer VERSION 0.1.3 LANGUAGES CXX)
project(kareer VERSION 0.1.6 LANGUAGES CXX)
set(REQUIRED_QT_VERSION 6.6.0)
set(REQUIRED_KF_VERSION 6.5.0)
@@ -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.
+57 -8
View File
@@ -15,12 +15,18 @@ applications without ever opening a window.
salary expectation, notes, contact, and the date applied
- A fixed pipeline of stages (Applied, Screening, Interview, Onsite,
Offer, Accepted, Rejected, Withdrawn, Ghosted) with full history of
every transition
every transition. The history is editable, with a date per step, so an
application you log after the fact (applied, interviewed, rejected)
keeps its whole path
- A Sankey diagram of the whole pipeline, showing where applications
progress and where they drop off
- Dashboard summary stats: total applications, active count, offers,
response rate
- A full CLI (`kareer add|list|show|update|stage|delete|stats|stages`)
- Applications with no response are marked Ghosted automatically:
anything still at Applied with no activity for 30 days (adjustable, or
off, under Preferences). The move is recorded in its history like any
other, and the CLI notes it on stderr so `--json` output stays clean.
- A full CLI (`kareer add|list|show|update|stage|history|delete|stats|stages`)
for scripting
## Command line usage
@@ -49,6 +55,10 @@ kareer show 1
kareer update 1 --salary-max 175000
kareer stage 1 Interview
# Show or rewrite an application's stage history, one Stage=date per step
kareer history 1
kareer history 1 Applied=2026-08-01 Interview=2026-08-15 Rejected=2026-08-20
# Delete (requires --yes to actually happen)
kareer delete 1 --yes
@@ -58,9 +68,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 +118,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 +134,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
@@ -134,10 +179,14 @@ Run the tests with `ctest --test-dir build`.
draws what this hands back.
- `jobfieldcatalog.{h,cpp}` - the static catalog of edit-form fields and categories.
- `jobeditmodel.{h,cpp}` - `QAbstractListModel`-backed edit-form state, built from the field catalog.
- `stagehistorymodel.{h,cpp}` - the editable stage history shown in the edit form's Pipeline section.
- `clicommands.{h,cpp}` - the `add`/`list`/`show`/`update`/`stage`/
`delete`/`stats`/`stages` subcommands.
`history`/`delete`/`stats`/`stages` subcommands.
- `appcolorscheme.{h,cpp}` - QML-facing wrapper around `KColorSchemeManager` for the Preferences page.
- `databaselocation.{h,cpp}` - where the database lives: the first-run choice and the Preferences Database section.
- `autoghost.{h,cpp}` - marks applications with no response as Ghosted, and its Preferences setting.
- `qml/` - Kirigami UI: `ApplicationsPage` (list + search),
`ApplicationEditPage` (add/edit/delete form), `DashboardPage`
(stat cards + pipeline), `SankeyDiagram` (the renderer),
`SettingsPage` (the Preferences page).
`SettingsPage` (the Preferences page), `DatabaseSetupDialog`
(the first-run database choice).
+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]>"
+23
View File
@@ -39,3 +39,26 @@ 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/stagehistorymodel.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)
+242
View File
@@ -0,0 +1,242 @@
/*
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);
// The history's first step shows the date applied, not when it was logged.
StageHistoryModel *history = edit.history();
QCOMPARE(history->rowCount(), 1);
QCOMPARE(history->data(history->index(0), StageHistoryModel::StageRole).toString(), u"Applied"_s);
QCOMPARE(history->data(history->index(0), StageHistoryModel::DateRole).toDate(), QDate(2026, 6, 1));
}
void savesEditsToExistingJob()
{
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);
// Moving the application on is adding a step to its history.
StageHistoryModel *history = edit.history();
history->appendStep();
history->setStage(history->rowCount() - 1, u"Interview"_s);
QVERIFY2(edit.save(), qPrintable(edit.lastError()));
JobsDatabase db;
QCOMPARE(db.jobById(id)->stage, u"Interview"_s);
QCOMPARE(db.jobById(id)->dateApplied, QDate(2026, 6, 1));
const auto transitions = db.stageTransitions();
QCOMPARE(transitions.size(), 2);
QCOMPARE(transitions.last().fromStage, u"Applied"_s);
QCOMPARE(transitions.last().toStage, u"Interview"_s);
}
// Applied, interviewed, then rejected: logged in one go after the fact.
void logsApplicationAfterTheFact()
{
JobsModel jobs;
JobEditModel edit;
edit.setJobsModel(&jobs);
setValueOf(edit, u"company"_s, u"Allstate"_s);
setValueOf(edit, u"title"_s, u"Software Engineer"_s);
StageHistoryModel *history = edit.history();
const auto day = [](int month, int dayOfMonth) {
return QDateTime(QDate(2026, month, dayOfMonth), QTime(9, 0));
};
history->setDate(0, day(8, 1));
history->appendStep();
history->setStage(1, u"Interview"_s);
history->setDate(1, day(8, 15));
history->appendStep();
history->setStage(2, u"Rejected"_s);
history->setDate(2, day(8, 20));
QVERIFY2(edit.save(), qPrintable(edit.lastError()));
JobsDatabase db;
const QList<Job> all = db.allJobs();
QCOMPARE(all.size(), 1);
QCOMPARE(all.first().stage, u"Rejected"_s);
QCOMPARE(all.first().dateApplied, QDate(2026, 8, 1));
const QList<StageStep> steps = db.stageHistory(all.first().id);
QCOMPARE(steps.size(), 3);
QCOMPARE(steps.at(1).stage, u"Interview"_s);
QCOMPARE(steps.at(1).at.toLocalTime().date(), QDate(2026, 8, 15));
QCOMPARE(steps.at(2).at.toLocalTime().date(), QDate(2026, 8, 20));
}
void addsNewJob()
{
JobsModel jobs;
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);
}
// A new form's text fields must start empty; a missing value showed up
// in QML as the word "undefined".
void newFormFieldsStartEmpty()
{
JobsModel jobs;
JobEditModel edit;
edit.setJobsModel(&jobs);
for (int row = 0; row < edit.rowCount(); ++row) {
const QVariant value = edit.data(edit.index(row), JobEditModel::ValueRole);
const QString id = edit.data(edit.index(row), JobEditModel::FieldIdRole).toString();
QVERIFY2(value.isValid(), qPrintable(id));
}
for (const QString &id : {u"company"_s, u"title"_s, u"location"_s, u"source"_s, u"url"_s, u"contact"_s, u"notes"_s}) {
const QVariant value = valueOf(edit, id);
QCOMPARE(value.typeId(), QMetaType::QString);
QVERIFY2(value.toString().isEmpty(), qPrintable(id));
}
QCOMPARE(valueOf(edit, u"currency"_s).toString(), u"USD"_s);
QCOMPARE(valueOf(edit, u"remoteType"_s).toString(), u"Unspecified"_s);
}
void missingCompanyIsReported()
{
JobsModel jobs;
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"
+147 -1
View File
@@ -4,9 +4,11 @@
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "job.h"
#include "jobsdatabase.h"
#include "job.h"
#include <QSqlDatabase>
#include <QSqlQuery>
#include <QTemporaryDir>
#include <QtTest>
@@ -144,6 +146,150 @@ private Q_SLOTS:
QVERIFY(!db.jobById(job.id).has_value());
QVERIFY(db.stageTransitions().isEmpty());
}
void ghostsStaleApplications()
{
QTemporaryDir dir;
const QString path = dbPathIn(dir);
JobsDatabase db(path);
const QDateTime now(QDate(2026, 9, 11), QTime(12, 0), QTimeZone::UTC);
const auto daysAgo = [&](int days) {
return now.addDays(-days);
};
// Still at Applied, nothing for 40 days: ghosted.
const int stale = addJob(db, {});
backdate(path, stale, {daysAgo(40)}, daysAgo(40).date());
// Applied 10 days ago: too recent.
const int fresh = addJob(db, {});
backdate(path, fresh, {daysAgo(10)}, daysAgo(10).date());
// Got a response (Screening) 40 days ago: not ours to ghost.
const int screening = addJob(db, {QStringLiteral("Screening")});
backdate(path, screening, {daysAgo(45), daysAgo(40)}, daysAgo(45).date());
// Applied 60 days ago but only logged 5 days ago: the month starts at logging.
const int loggedLate = addJob(db, {});
backdate(path, loggedLate, {daysAgo(5)}, daysAgo(60).date());
// Logged 40 days ago, date applied later edited to 10 days ago.
const int appliedLater = addJob(db, {});
backdate(path, appliedLater, {daysAgo(40)}, daysAgo(10).date());
// Ghosted before, then moved back to Applied 5 days ago: a fresh month.
const int reopened = addJob(db, {QStringLiteral("Ghosted"), QStringLiteral("Applied")});
backdate(path, reopened, {daysAgo(60), daysAgo(50), daysAgo(5)}, daysAgo(60).date());
QCOMPARE(db.ghostStaleApplications(0, now), 0);
QCOMPARE(db.ghostStaleApplications(30, now), 1);
QCOMPARE(db.jobById(stale)->stage, QStringLiteral("Ghosted"));
for (int id : {fresh, loggedLate, appliedLater, reopened}) {
QCOMPARE(db.jobById(id)->stage, QStringLiteral("Applied"));
}
QCOMPARE(db.jobById(screening)->stage, QStringLiteral("Screening"));
// Recorded like any other move, so the pipeline shows it.
bool recorded = false;
for (const StageTransition &t : db.stageTransitions()) {
recorded |= t.jobId == stale && t.fromStage == QStringLiteral("Applied") && t.toStage == QStringLiteral("Ghosted");
}
QVERIFY(recorded);
// Nothing left to do on a second run; a shorter threshold catches more.
QCOMPARE(db.ghostStaleApplications(30, now), 0);
QCOMPARE(db.ghostStaleApplications(7, now), 2); // fresh and appliedLater, both 10 days
}
// Logging an application after the fact: Applied, Interview, Rejected.
void replacesStageHistory()
{
QTemporaryDir dir;
JobsDatabase db(dbPathIn(dir));
const int id = addJob(db, {});
const auto at = [](int month, int day) {
return QDateTime(QDate(2026, month, day), QTime(12, 0));
};
// Out of order and with a repeated stage, as an editor might hand it over.
QVERIFY2(db.replaceStageHistory(id,
{{QStringLiteral("rejected"), at(8, 20)},
{QStringLiteral("Applied"), at(8, 1)},
{QStringLiteral("Interview"), at(8, 15)},
{QStringLiteral("Interview"), at(8, 16)}}),
qPrintable(db.lastError()));
const QList<StageStep> steps = db.stageHistory(id);
QCOMPARE(steps.size(), 3);
QCOMPARE(steps.at(0).stage, QStringLiteral("Applied"));
QCOMPARE(steps.at(1).stage, QStringLiteral("Interview"));
QCOMPARE(steps.at(2).stage, QStringLiteral("Rejected"));
QCOMPARE(steps.at(1).at.toLocalTime().date(), QDate(2026, 8, 15));
const auto job = db.jobById(id);
QCOMPARE(job->stage, QStringLiteral("Rejected"));
QCOMPARE(job->dateApplied, QDate(2026, 8, 1));
// The moves chain from the synthetic Start, so the pipeline sees the path.
QStringList moves;
for (const StageTransition &t : db.stageTransitions()) {
if (t.jobId == id) {
moves.append((t.fromStage.isEmpty() ? QStringLiteral("Start") : t.fromStage) + QStringLiteral(">") + t.toStage);
}
}
QCOMPARE(moves, (QStringList{QStringLiteral("Start>Applied"), QStringLiteral("Applied>Interview"), QStringLiteral("Interview>Rejected")}));
// Bad input changes nothing.
QVERIFY(!db.replaceStageHistory(id, {}));
QVERIFY(!db.replaceStageHistory(id, {{QStringLiteral("Applied"), at(8, 1)}, {QStringLiteral("Bogus"), at(8, 2)}}));
QVERIFY(!db.replaceStageHistory(id, {{QStringLiteral("Applied"), QDateTime()}}));
QVERIFY(!db.replaceStageHistory(9999, {{QStringLiteral("Applied"), at(8, 1)}}));
QCOMPARE(db.stageHistory(id).size(), 3);
QCOMPARE(db.jobById(id)->stage, QStringLiteral("Rejected"));
}
private:
static int addJob(JobsDatabase &db, const QStringList &stages)
{
Job job;
job.company = QStringLiteral("Co");
job.title = QStringLiteral("Title");
if (!db.addJob(job)) {
return -1;
}
for (const QString &stage : stages) {
db.setStage(job.id, stage);
}
return job.id;
}
/// Rewrites a job's history timestamps (oldest first, one per recorded
/// move) and its date applied, through a separate connection.
static void backdate(const QString &path, int jobId, const QList<QDateTime> &history, const QDate &applied)
{
{
QSqlDatabase raw = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), QStringLiteral("backdate"));
raw.setDatabaseName(path);
QVERIFY(raw.open());
QSqlQuery ids(raw);
QVERIFY(ids.exec(QStringLiteral("SELECT id FROM stage_history WHERE job_id = %1 ORDER BY id").arg(jobId)));
QList<int> rows;
while (ids.next()) {
rows.append(ids.value(0).toInt());
}
QCOMPARE(rows.size(), history.size());
QSqlQuery update(raw);
for (int i = 0; i < rows.size(); ++i) {
update.prepare(QStringLiteral("UPDATE stage_history SET changed_at = ? WHERE id = ?"));
update.addBindValue(history.at(i).toString(Qt::ISODate));
update.addBindValue(rows.at(i));
QVERIFY(update.exec());
}
update.prepare(QStringLiteral("UPDATE jobs SET date_applied = ?, created_at = ? WHERE id = ?"));
update.addBindValue(applied.toString(Qt::ISODate));
update.addBindValue(history.first().toString(Qt::ISODate));
update.addBindValue(jobId);
QVERIFY(update.exec());
raw.close();
}
QSqlDatabase::removeDatabase(QStringLiteral("backdate"));
}
};
QTEST_GUILESS_MAIN(JobsDatabaseTest)
+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.5
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=('bc1011f9a013d8d2b266da0fed7cbb50f7410e71b007844bfb9598639504168c')
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
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 572 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 825 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

+18 -8
View File
@@ -2,15 +2,25 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
# Named after the app ID, as the desktop file, the window icon and Flatpak
# (which only exports icons prefixed with the app ID) all expect.
# Image loaders (gdk-pixbuf, and so AppStream compose in the Flatpak build)
# recognise an SVG by finding "<svg" in its first 256 bytes; past that the
# file is "Unrecognized image file format" and the Flatpak build fails.
file(READ sc-apps-io.github.toservetheking.Kareer.svg _kareer_svg_head LIMIT 256)
if(NOT _kareer_svg_head MATCHES "<svg")
message(FATAL_ERROR "icons/sc-apps-io.github.toservetheking.Kareer.svg: the <svg> tag must start within the first 256 bytes; move comments inside the element.")
endif()
ecm_install_icons(ICONS
sc-apps-kareer.svg
128-apps-kareer.png
64-apps-kareer.png
48-apps-kareer.png
44-apps-kareer.png
32-apps-kareer.png
22-apps-kareer.png
16-apps-kareer.png
sc-apps-io.github.toservetheking.Kareer.svg
128-apps-io.github.toservetheking.Kareer.png
64-apps-io.github.toservetheking.Kareer.png
48-apps-io.github.toservetheking.Kareer.png
44-apps-io.github.toservetheking.Kareer.png
32-apps-io.github.toservetheking.Kareer.png
22-apps-io.github.toservetheking.Kareer.png
16-apps-io.github.toservetheking.Kareer.png
DESTINATION ${KDE_INSTALL_ICONDIR}
THEME hicolor
)
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]> -->
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<!--
A K for Kareer, drawn as its application pipeline: the stem is every
application, splitting into a success path rising to an outcome and a
drop-off falling away. Plain shapes and gradients only, so QtSvg (which
ignores filters) renders it exactly as other renderers do.
-->
<!-- Keep this comment inside <svg>: loaders sniff for "<svg" in the first
256 bytes, so a long header comment makes the file unrecognisable. -->
<defs>
<linearGradient id="backdrop" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#4cbcf2"/>
<stop offset="1" stop-color="#1c6ea9"/>
</linearGradient>
<linearGradient id="success" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#ffffff"/>
<stop offset="1" stop-color="#6ff0a0"/>
</linearGradient>
<linearGradient id="dropoff" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.92"/>
<stop offset="1" stop-color="#ffffff" stop-opacity="0.3"/>
</linearGradient>
</defs>
<!-- shadow, rounded-square backdrop, and a soft highlight on its top half -->
<rect x="10" y="12" width="108" height="108" rx="24" fill="#0b2a44" fill-opacity="0.28"/>
<rect x="10" y="10" width="108" height="108" rx="24" fill="url(#backdrop)"/>
<rect x="10" y="10" width="108" height="54" rx="24" fill="#ffffff" fill-opacity="0.06"/>
<!-- the arms of the K: success rising, drop-off falling -->
<path d="M41,44 C67,44 67,20 95,20 L95,40 C67,40 67,64 41,64 Z" fill="url(#success)"/>
<path d="M41,64 C67,64 67,88 95,88 L95,108 C67,108 67,84 41,84 Z" fill="url(#dropoff)"/>
<!-- the stem (all applications) and the two outcomes -->
<rect x="27" y="20" width="14" height="88" rx="4" fill="#ffffff"/>
<rect x="95" y="20" width="6" height="20" rx="2" fill="#4be07a"/>
<rect x="95" y="88" width="6" height="20" rx="2" fill="#ffffff" fill-opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

-26
View File
@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]> -->
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#63d4c7"/>
<stop offset="1" stop-color="#1a7a70"/>
</linearGradient>
</defs>
<!-- rounded-square backdrop -->
<rect x="8" y="8" width="112" height="112" rx="28" ry="28" fill="url(#bg)"/>
<!-- briefcase -->
<g>
<rect x="34" y="58" width="60" height="42" rx="6" fill="#ffffff" fill-opacity="0.92"/>
<rect x="52" y="46" width="24" height="14" rx="4" fill="none" stroke="#ffffff" stroke-opacity="0.92" stroke-width="6"/>
<rect x="34" y="72" width="60" height="10" fill="#1a7a70" fill-opacity="0.35"/>
<rect x="60" y="68" width="8" height="10" rx="2" fill="#1a7a70" fill-opacity="0.55"/>
</g>
<!-- upward trend line, representing progress through the pipeline -->
<polyline points="30,44 46,30 58,38 74,22 90,30" fill="none" stroke="#ffffff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<polygon points="90,30 98,28 92,36" fill="#ffffff"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

@@ -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>
@@ -64,6 +65,37 @@
<content_rating type="oars-1.1"/>
<releases>
<release version="0.1.6" date="2026-09-11">
<description>
<p>Ships everything listed for 0.1.5, which was never published because its build failed.</p>
<ul>
<li>Fix the new app icon not being recognised by software centers, which stopped the Flatpak from building</li>
</ul>
</description>
</release>
<release version="0.1.5" date="2026-09-11">
<description>
<ul>
<li>Edit an application's full stage history, with a date for each step, so an application you log after the fact keeps its whole path (for example applied, interviewed, rejected)</li>
<li>Add a history command to show or replace a stage history from the command line</li>
<li>Mark applications with no response as Ghosted after 30 days, adjustable or off in Preferences</li>
<li>New app icon, now installed under the app ID so launchers and the Flatpak show it</li>
<li>Fix the Add Application form filling empty fields with the word "undefined"</li>
</ul>
</description>
</release>
<release version="0.1.4" date="2026-09-11">
<description>
<ul>
<li>Fix Save on the edit form silently dropping stage changes, and report missing company or title</li>
<li>Rework the pipeline diagram so drop-offs split off each stage and rejoin their outcome without braiding</li>
<li>Count each application once in the pipeline, so moving back a stage or being ghosted then rejected no longer draws extra ribbons</li>
<li>Choose where the database lives on first run: the default location, a folder of your choice, or an existing database</li>
<li>Move, open or reset the database location from Preferences</li>
<li>Add a --db option to use a specific database file from the command line or the GUI</li>
</ul>
</description>
</release>
<release version="0.1.3" date="2026-08-31">
<description>
<ul>
+7 -4
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
@@ -36,5 +39,5 @@ modules:
# For Flathub / release builds, pin to a tag AND commit instead:
# - type: git
# url: https://github.com/toservetheking/Kareer.git
# tag: v0.1.1
# tag: v0.1.6
# commit: <fill in the exact commit SHA the tag points at>
+16
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
@@ -30,8 +31,22 @@ qt_add_qml_module(kareer
sankeymodel.h
jobeditmodel.cpp
jobeditmodel.h
stagehistorymodel.cpp
stagehistorymodel.h
appcolorscheme.cpp
appcolorscheme.h
databaselocation.cpp
databaselocation.h
autoghost.cpp
autoghost.h
)
# The app icon, embedded so the window and About page show it even when
# running from the build directory; the installed hicolor icon wins when present.
qt_add_resources(kareer "appicon"
PREFIX "/icons"
BASE "${CMAKE_SOURCE_DIR}/icons"
FILES "${CMAKE_SOURCE_DIR}/icons/sc-apps-io.github.toservetheking.Kareer.svg"
)
target_include_directories(kareer PRIVATE ${CMAKE_BINARY_DIR})
@@ -44,6 +59,7 @@ target_link_libraries(kareer PRIVATE
Qt6::Quick
Qt6::QuickControls2
Qt6::Sql
KF6::ConfigCore
KF6::I18n
KF6::I18nQml
KF6::CoreAddons
+92
View File
@@ -0,0 +1,92 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "autoghost.h"
#include "jobsdatabase.h"
#include <KConfigGroup>
#include <KSharedConfig>
using namespace Qt::Literals::StringLiterals;
namespace
{
int s_lastRunCount = 0;
KConfigGroup settingsGroup()
{
return KConfigGroup(KSharedConfig::openConfig(u"kareerrc"_s), u"AutoGhost"_s);
}
}
AutoGhost::AutoGhost(QObject *parent)
: QObject(parent)
{
m_settleTimer.setSingleShot(true);
m_settleTimer.setInterval(1500);
connect(&m_settleTimer, &QTimer::timeout, this, &AutoGhost::run);
}
int AutoGhost::runNow()
{
const KConfigGroup group = settingsGroup();
if (!group.readEntry("Enabled", true)) {
s_lastRunCount = 0;
return 0;
}
JobsDatabase db;
s_lastRunCount = db.ghostStaleApplications(group.readEntry("Days", DefaultDays));
return s_lastRunCount;
}
bool AutoGhost::enabled() const
{
return settingsGroup().readEntry("Enabled", true);
}
void AutoGhost::setEnabled(bool enabled)
{
if (enabled == this->enabled()) {
return;
}
KConfigGroup group = settingsGroup();
group.writeEntry("Enabled", enabled);
group.sync();
Q_EMIT settingsChanged();
m_settleTimer.start();
}
int AutoGhost::days() const
{
return settingsGroup().readEntry("Days", DefaultDays);
}
void AutoGhost::setDays(int days)
{
days = qMax(1, days);
if (days == this->days()) {
return;
}
KConfigGroup group = settingsGroup();
group.writeEntry("Days", days);
group.sync();
Q_EMIT settingsChanged();
m_settleTimer.start();
}
int AutoGhost::lastRunCount() const
{
return s_lastRunCount;
}
int AutoGhost::run()
{
m_settleTimer.stop();
const int count = runNow();
Q_EMIT ran(count);
return count;
}
+61
View File
@@ -0,0 +1,61 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once
#include <QObject>
#include <QQmlEngine>
#include <QTimer>
/**
* Marks applications with no response as Ghosted: anything still at
* Applied whose last activity is more than days() old (see
* JobsDatabase::ghostStaleApplications()). The switch and the threshold live
* in kareerrc ([AutoGhost] Enabled/Days) and are shown on the Preferences
* page.
*
* runNow() is called at startup for both the GUI and the CLI; the GUI runs
* it again after switching databases and shortly after the settings change,
* and refreshes the models when ran() reports moved applications.
*/
class AutoGhost : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
Q_PROPERTY(bool enabled READ enabled WRITE setEnabled NOTIFY settingsChanged)
Q_PROPERTY(int days READ days WRITE setDays NOTIFY settingsChanged)
/// How many applications the most recent run moved to Ghosted.
Q_PROPERTY(int lastRunCount READ lastRunCount NOTIFY ran)
public:
static constexpr int DefaultDays = 30;
explicit AutoGhost(QObject *parent = nullptr);
/// Ghosts stale applications in the current database if enabled, and
/// returns how many were moved.
static int runNow();
bool enabled() const;
void setEnabled(bool enabled);
int days() const;
void setDays(int days);
int lastRunCount() const;
/// runNow(), then emits ran() with the number of applications moved.
Q_INVOKABLE int run();
Q_SIGNALS:
void settingsChanged();
void ran(int count);
private:
/// Settings changes run shortly after they settle, so stepping the
/// threshold down doesn't ghost applications at every value in between.
QTimer m_settleTimer;
};
+102 -12
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);
@@ -526,11 +540,80 @@ int runStats(const QString &program, const QStringList &args)
return 0;
}
int runHistory(const QString &program, const QStringList &args)
{
QCommandLineParser parser;
parser.setApplicationDescription(
u"Show an application's stage history, or replace it with the given steps, e.g.\n"
u" kareer history 3 Applied=2026-08-01 Interview=2026-08-15 Rejected=2026-08-20\n"
u"The last step becomes the current stage and the first step's date the date applied."_s);
parser.addHelpOption();
addDbOption(parser);
parser.addOption({u"json"_s, u"Print machine-readable JSON"_s});
parser.addPositionalArgument(u"id"_s, u"Application id"_s);
parser.addPositionalArgument(u"steps"_s, u"Optional: Stage=YYYY-MM-DD for each step, replacing the history"_s, u"[steps...]"_s);
parser.process(QStringList{program} + args);
QTextStream err(stderr);
const QStringList positional = parser.positionalArguments();
if (positional.isEmpty()) {
err << u"Error: usage: kareer history <id> [Stage=YYYY-MM-DD ...]"_s << Qt::endl;
return 1;
}
bool ok = false;
const int id = positional.first().toInt(&ok);
if (!ok) {
err << u"Error: id must be an integer"_s << Qt::endl;
return 1;
}
JobsDatabase db;
if (!db.jobById(id)) {
err << u"Error: no application #%1"_s.arg(id) << Qt::endl;
return 1;
}
if (positional.size() > 1) {
QList<StageStep> steps;
for (const QString &arg : positional.mid(1)) {
const qsizetype separator = arg.indexOf(u'=');
const QString stage = arg.left(separator);
const QDate date = QDate::fromString(arg.mid(separator + 1), Qt::ISODate);
if (separator < 0 || !JobStage::isValid(stage) || !date.isValid()) {
err << u"Error: '%1' is not Stage=YYYY-MM-DD. Valid stages: %2"_s.arg(arg, JobStage::canonicalStages().join(u", "_s)) << Qt::endl;
return 1;
}
// Midday local time, so the calendar date survives time-zone conversion.
steps.append({stage, QDateTime(date, QTime(12, 0))});
}
if (!db.replaceStageHistory(id, steps)) {
err << u"Error: %1"_s.arg(db.lastError()) << Qt::endl;
return 1;
}
}
const QList<StageStep> steps = db.stageHistory(id);
if (parser.isSet(u"json"_s)) {
QJsonArray array;
for (const StageStep &step : steps) {
array.append(QJsonObject{{u"stage"_s, step.stage}, {u"date"_s, step.at.toLocalTime().date().toString(Qt::ISODate)}});
}
printJson(array);
} else {
QTextStream out(stdout);
for (const StageStep &step : steps) {
out << step.at.toLocalTime().date().toString(Qt::ISODate) << u" "_s << step.stage << Qt::endl;
}
}
return 0;
}
int runStages(const QString &program, const QStringList &args)
{
QCommandLineParser parser;
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 +636,13 @@ 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
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"_s << u" history Show or replace an application's stage history\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;
<< 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 +651,16 @@ 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"history"_s,
u"help"_s,
};
return subcommands.contains(arg);
}
@@ -609,5 +696,8 @@ int Cli::run(QCoreApplication &app)
if (subcommand == u"stages"_s) {
return runStages(program, rest);
}
if (subcommand == u"history"_s) {
return runHistory(program, rest);
}
return runHelp();
}
+1 -1
View File
@@ -11,7 +11,7 @@
class QCoreApplication;
/**
* Headless command-line interface: `kareer add|list|show|update|stage|delete|stats|stages ...`.
* Headless command-line interface: `kareer add|list|show|update|stage|history|delete|stats|stages ...`.
* Lets other tools (a resume generator, a shell script) log and query
* applications without ever starting the Kirigami GUI.
*/
+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;
};
+6
View File
@@ -31,6 +31,12 @@ struct Job {
QDateTime updatedAt;
};
/// One step in an application's history: the stage it moved to, and when.
struct StageStep {
QString stage;
QDateTime at;
};
/// One recorded move from one stage to another (or from "Start" for the
/// initial application), used to build the Sankey diagram.
struct StageTransition {
+95 -7
View File
@@ -6,6 +6,8 @@
#include "jobeditmodel.h"
#include <KLocalizedString>
#include <QDate>
using namespace Qt::Literals::StringLiterals;
@@ -27,6 +29,7 @@ bool isSalaryField(const QString &id)
JobEditModel::JobEditModel(QObject *parent)
: QAbstractListModel(parent)
, m_history(new StageHistoryModel(this))
, m_fields(JobFieldCatalog::fields())
{
resetValues();
@@ -96,6 +99,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();
}
@@ -119,9 +126,28 @@ void JobEditModel::resetValues()
m_values.clear();
if (m_editingJobId < 0 || !m_jobsModel) {
m_history->load({{u"Applied"_s, QDateTime(QDate::currentDate(), QTime(12, 0)).toUTC()}});
// Every field needs a value of its own type: a missing one reaches
// QML as undefined, which a text field shows as the word "undefined"
// (typing "a" then gave "undefineda").
for (const Field &field : m_fields) {
switch (field.rowType) {
case TextRow:
case TextAreaRow:
m_values[field.id] = QString();
break;
case ComboRow:
m_values[field.id] = field.comboOptions.value(0);
break;
case SpinBoxRow:
m_values[field.id] = 0;
break;
case DateRow:
m_values[field.id] = QDate::currentDate();
break;
}
}
m_values[u"currency"_s] = u"USD"_s;
m_values[u"stage"_s] = u"Applied"_s;
m_values[u"dateApplied"_s] = QDate::currentDate();
m_values[u"remoteType"_s] = u"Unspecified"_s;
m_values[u"salaryMin"_s] = 0;
m_values[u"salaryMax"_s] = 0;
@@ -138,6 +164,18 @@ void JobEditModel::resetValues()
}
m_values[field.id] = value;
}
QList<StageStep> steps = m_jobsModel->stageHistory(m_editingJobId);
if (steps.isEmpty()) {
steps.append({data.value(u"stage"_s).toString(), data.value(u"createdAt"_s).toDateTime()});
}
// The first step's recorded time is when the job was logged; show the
// date actually applied instead, which is what that step stands for.
const QDate applied = data.value(u"dateApplied"_s).toDate();
if (applied.isValid()) {
steps.first().at = QDateTime(applied, QTime(12, 0)).toUTC();
}
m_history->load(steps);
}
if (rowCount() > 0) {
@@ -159,6 +197,11 @@ QString JobEditModel::lastError() const
return m_lastError;
}
StageHistoryModel *JobEditModel::history() const
{
return m_history;
}
void JobEditModel::setValue(int row, const QVariant &value)
{
if (row < 0 || row >= m_fields.size()) {
@@ -170,7 +213,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 +242,45 @@ 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();
// The history decides the current stage and the date applied.
const QList<StageStep> steps = m_history->steps();
fields[u"stage"_s] = steps.last().stage;
fields[u"dateApplied"_s] = steps.first().at.toLocalTime().date();
if (m_editingJobId < 0) {
const int id = m_jobsModel->addJob(fields);
// A new application's history is always written in full, so one
// logged after the fact (Applied, Interview, Rejected) keeps its path.
if (id < 0 || !m_jobsModel->replaceStageHistory(id, steps)) {
setLastError(m_jobsModel->lastError());
return false;
}
return ok;
return true;
}
// updateJob() deliberately never writes the stage; stage changes only
// ever come from the history.
if (!m_jobsModel->updateJob(m_editingJobId, fields)) {
setLastError(m_jobsModel->lastError());
return false;
}
if (m_history->isEdited()) {
if (!m_jobsModel->replaceStageHistory(m_editingJobId, steps)) {
setLastError(m_jobsModel->lastError());
return false;
}
m_history->load(m_jobsModel->stageHistory(m_editingJobId));
}
return true;
}
void JobEditModel::setLastError(const QString &error)
{
if (m_lastError == error) {
return;
}
m_lastError = error;
Q_EMIT lastErrorChanged();
}
bool JobEditModel::deleteJob()
+7
View File
@@ -8,6 +8,7 @@
#include "jobfieldcatalog.h"
#include "jobsmodel.h"
#include "stagehistorymodel.h"
#include <QAbstractListModel>
#include <QHash>
@@ -29,6 +30,9 @@ class JobEditModel : public QAbstractListModel
Q_PROPERTY(int editingJobId READ editingJobId WRITE setEditingJobId NOTIFY editingJobIdChanged)
Q_PROPERTY(QVariantList categories READ categories CONSTANT)
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
/// The application's stage history (the Pipeline section): the last step
/// is its current stage, the first step's date its date applied.
Q_PROPERTY(StageHistoryModel *history READ history CONSTANT)
public:
enum Roles {
@@ -58,6 +62,7 @@ public:
QVariantList categories() const;
QString lastError() const;
StageHistoryModel *history() const;
/// Updates the value for the field at this row (called from the QML delegate).
Q_INVOKABLE void setValue(int row, const QVariant &value);
@@ -74,10 +79,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;
StageHistoryModel *m_history = nullptr;
QList<JobFieldCatalog::Field> m_fields;
};
+11 -4
View File
@@ -5,7 +5,6 @@
*/
#include "jobfieldcatalog.h"
#include "jobstage.h"
using namespace Qt::Literals::StringLiterals;
@@ -28,10 +27,18 @@ 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},
// The "pipeline" category has no plain fields: ApplicationEditPage
// shows JobEditModel::history there (stage and date applied come
// from the history's last and first steps).
{u"salaryMin"_s, u"salary"_s, QStringLiteral("Range Minimum:"), SpinBoxRow, {}, {}, 0, 5000000},
{u"salaryMax"_s, u"salary"_s, QStringLiteral("Range Maximum:"), SpinBoxRow, {}, {}, 0, 5000000},
+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
+258 -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,
@@ -374,9 +479,154 @@ bool JobsDatabase::deleteJob(int id)
return true;
}
QList<StageStep> JobsDatabase::stageHistory(int jobId) const
{
QList<StageStep> steps;
if (!isOpen()) {
return steps;
}
QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(u"SELECT to_stage, changed_at FROM stage_history WHERE job_id = :id ORDER BY changed_at ASC, id ASC"_s);
query.bindValue(u":id"_s, jobId);
if (!query.exec()) {
return steps;
}
while (query.next()) {
steps.append({query.value(0).toString(), QDateTime::fromString(query.value(1).toString(), Qt::ISODate)});
}
return steps;
}
bool JobsDatabase::replaceStageHistory(int jobId, QList<StageStep> steps)
{
if (!jobById(jobId)) {
m_lastError = u"No job with id %1"_s.arg(jobId);
return false;
}
if (steps.isEmpty()) {
m_lastError = u"An application needs at least one stage"_s;
return false;
}
for (StageStep &step : steps) {
if (!JobStage::isValid(step.stage)) {
m_lastError = u"Unknown stage '%1'"_s.arg(step.stage);
return false;
}
if (!step.at.isValid()) {
m_lastError = u"Missing date for stage '%1'"_s.arg(step.stage);
return false;
}
step.stage = JobStage::canonical(step.stage);
}
std::stable_sort(steps.begin(), steps.end(), [](const StageStep &a, const StageStep &b) {
return a.at < b.at;
});
QList<StageStep> collapsed;
for (const StageStep &step : std::as_const(steps)) {
if (collapsed.isEmpty() || collapsed.last().stage != step.stage) {
collapsed.append(step);
}
}
QSqlDatabase db = QSqlDatabase::database(m_connectionName);
if (!db.transaction()) {
m_lastError = db.lastError().text();
return false;
}
const auto fail = [&](const QSqlQuery &query) {
m_lastError = query.lastError().text();
db.rollback();
return false;
};
QSqlQuery query(db);
query.prepare(u"DELETE FROM stage_history WHERE job_id = :id"_s);
query.bindValue(u":id"_s, jobId);
if (!query.exec()) {
return fail(query);
}
QString previous;
for (const StageStep &step : std::as_const(collapsed)) {
query.prepare(u"INSERT INTO stage_history (job_id, from_stage, to_stage, changed_at) VALUES (:job_id, :from_stage, :to_stage, :changed_at)"_s);
query.bindValue(u":job_id"_s, jobId);
query.bindValue(u":from_stage"_s, previous.isEmpty() ? QVariant() : QVariant(previous));
query.bindValue(u":to_stage"_s, step.stage);
query.bindValue(u":changed_at"_s, step.at.toUTC().toString(Qt::ISODate));
if (!query.exec()) {
return fail(query);
}
previous = step.stage;
}
query.prepare(u"UPDATE jobs SET stage = :stage, date_applied = :date_applied, updated_at = :updated_at WHERE id = :id"_s);
query.bindValue(u":stage"_s, collapsed.last().stage);
query.bindValue(u":date_applied"_s, collapsed.first().at.toLocalTime().date().toString(Qt::ISODate));
query.bindValue(u":updated_at"_s, QDateTime::currentDateTimeUtc().toString(Qt::ISODate));
query.bindValue(u":id"_s, jobId);
if (!query.exec()) {
return fail(query);
}
if (!db.commit()) {
m_lastError = db.lastError().text();
db.rollback();
return false;
}
return true;
}
int JobsDatabase::ghostStaleApplications(int days, const QDateTime &now)
{
if (days <= 0 || !isOpen()) {
return 0;
}
QList<int> stale;
{
QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(
uR"(
SELECT j.id, j.date_applied, j.created_at, MAX(h.changed_at)
FROM jobs j LEFT JOIN stage_history h ON h.job_id = j.id
WHERE j.stage = :stage
GROUP BY j.id
)"_s);
query.bindValue(u":stage"_s, u"Applied"_s);
if (!query.exec()) {
m_lastError = query.lastError().text();
return 0;
}
while (query.next()) {
// A job logged today for an application sent weeks ago only
// starts its month today, and one moved back to Applied gets a
// fresh month: whichever happened last counts.
QDateTime lastActivity = QDateTime::fromString(query.value(3).toString(), Qt::ISODate);
if (!lastActivity.isValid()) {
lastActivity = QDateTime::fromString(query.value(2).toString(), Qt::ISODate);
}
const QDate applied = QDate::fromString(query.value(1).toString(), Qt::ISODate);
if (applied.isValid() && (!lastActivity.isValid() || applied.startOfDay() > lastActivity)) {
lastActivity = applied.startOfDay();
}
if (lastActivity.isValid() && lastActivity.addDays(days) < now) {
stale.append(query.value(0).toInt());
}
}
}
int moved = 0;
for (int id : std::as_const(stale)) {
if (setStage(id, u"Ghosted"_s)) {
++moved;
}
}
return moved;
}
QList<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()) {
+53 -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;
@@ -55,13 +87,32 @@ public:
bool deleteJob(int id);
/// The steps one application went through, oldest first.
QList<StageStep> stageHistory(int jobId) const;
/// Rewrites an application's whole history in one transaction (used by
/// the history editor, e.g. to log Applied -> Interview -> Rejected after
/// the fact). Steps are sorted by time (stably), consecutive repeats of a
/// stage collapse into the first, the job's stage becomes the last step's
/// and its date applied the first step's date. Fails, changing nothing,
/// if there are no steps, a stage is unknown, or a time is invalid.
bool replaceStageHistory(int jobId, QList<StageStep> steps);
/// Moves every application still at Applied whose last activity (the
/// later of its date applied and its last stage change) is more than
/// `days` days before `now` to Ghosted, recorded through setStage() like
/// any other move. Returns how many were moved; does nothing if days <= 0.
int ghostStaleApplications(int days, const QDateTime &now = QDateTime::currentDateTimeUtc());
QList<StageTransition> stageTransitions() const;
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;
};
+20 -5
View File
@@ -17,6 +17,7 @@ JobsModel::JobsModel(QObject *parent)
void JobsModel::refresh()
{
m_db.reopenIfPathChanged();
beginResetModel();
m_jobs = m_db.allJobs();
endResetModel();
@@ -166,14 +167,14 @@ QVariantMap JobsModel::jobData(int id) const
return mapFromJob(*job);
}
bool JobsModel::addJob(const QVariantMap &fields)
int JobsModel::addJob(const QVariantMap &fields)
{
Job job = jobFromMap(fields);
const bool ok = m_db.addJob(job);
if (ok) {
refresh();
if (!m_db.addJob(job)) {
return -1;
}
return ok;
refresh();
return job.id;
}
bool JobsModel::updateJob(int id, const QVariantMap &fields)
@@ -205,6 +206,20 @@ bool JobsModel::removeJob(int id)
return ok;
}
QList<StageStep> JobsModel::stageHistory(int id) const
{
return m_db.stageHistory(id);
}
bool JobsModel::replaceStageHistory(int id, const QList<StageStep> &steps)
{
const bool ok = m_db.replaceStageHistory(id, steps);
if (ok) {
refresh();
}
return ok;
}
QString JobsModel::lastError() const
{
return m_db.lastError();
+5 -1
View File
@@ -60,11 +60,15 @@ public:
/// Full record for one job, for prefilling the edit dialog.
Q_INVOKABLE QVariantMap jobData(int id) const;
Q_INVOKABLE bool addJob(const QVariantMap &fields);
/// Adds a job and returns its id, or -1 on failure (see lastError()).
Q_INVOKABLE int addJob(const QVariantMap &fields);
Q_INVOKABLE bool updateJob(int id, const QVariantMap &fields);
Q_INVOKABLE bool setStage(int id, const QString &stage);
Q_INVOKABLE bool removeJob(int id);
QList<StageStep> stageHistory(int id) const;
bool replaceStageHistory(int id, const QList<StageStep> &steps);
Q_INVOKABLE QString lastError() const;
public Q_SLOTS:
+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");
}
}
+57 -1
View File
@@ -6,7 +6,10 @@
#include "kareer-version.h"
#include "autoghost.h"
#include "clicommands.h"
#include "databaselocation.h"
#include "jobsdatabase.h"
#include <KAboutData>
#include <KCrash>
@@ -17,6 +20,7 @@
#include <QApplication>
#include <QCommandLineParser>
#include <QCoreApplication>
#include <QFileInfo>
#include <QIcon>
#include <QQmlApplicationEngine>
#include <QQuickStyle>
@@ -63,12 +67,54 @@ 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();
if (const int ghosted = AutoGhost::runNow(); ghosted > 0) {
// stderr, so --json output on stdout stays machine-readable.
fprintf(stderr, "kareer: marked %d application(s) with no response as Ghosted\n", ghosted);
}
return Cli::run(app);
}
@@ -93,15 +139,25 @@ int main(int argc, char *argv[])
aboutData.setDesktopFileName(u"io.github.toservetheking.Kareer"_s);
KAboutData::setApplicationData(aboutData);
QApplication::setWindowIcon(QIcon::fromTheme(u"io.github.toservetheking.Kareer"_s, QIcon::fromTheme(u"office-address-book"_s)));
QApplication::setWindowIcon(QIcon::fromTheme(u"io.github.toservetheking.Kareer"_s, QIcon(u":/icons/sc-apps-io.github.toservetheking.Kareer.svg"_s)));
KCrash::initialize();
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());
if (!JobsDatabase::selectionPending()) {
AutoGhost::runNow(); // before the models load; Main.qml reports the count
}
QQmlApplicationEngine engine;
KLocalization::setupLocalizedContext(&engine);
+87 -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
}
@@ -103,7 +103,80 @@ Kirigami.ScrollablePage {
filterString: section.modelData.id
}
// The Pipeline category has no plain fields: it is the
// application's stage history. The last step is its current
// stage, the first step's date its date applied.
ColumnLayout {
visible: section.modelData.id === "pipeline"
Layout.fillWidth: true
Layout.leftMargin: Kirigami.Units.largeSpacing
Layout.rightMargin: Kirigami.Units.largeSpacing
Layout.topMargin: Kirigami.Units.smallSpacing
Layout.bottomMargin: Kirigami.Units.smallSpacing
spacing: Kirigami.Units.smallSpacing
QQC2.Label {
Layout.fillWidth: true
text: i18nc("@info", "Every stage this application went through, oldest first. The first date is when you applied, and the last stage is where it stands now.")
wrapMode: Text.Wrap
font: Kirigami.Theme.smallFont
opacity: 0.7
}
Repeater {
model: section.modelData.id === "pipeline" ? editModel.history : null
delegate: RowLayout {
id: stepRow
required property int index
required property string stage
required property var date
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
QQC2.ComboBox {
Layout.fillWidth: true
model: editModel.history.stages
currentIndex: editModel.history.stages.indexOf(stepRow.stage)
onActivated: editModel.history.setStage(stepRow.index, currentText)
Accessible.name: i18nc("@label:listbox", "Stage")
}
QQC2.Button {
text: Qt.formatDate(stepRow.date, Qt.ISODate)
icon.name: "view-calendar-day"
Accessible.name: i18nc("@action:button", "Date of this stage")
onClicked: {
stepDatePopup.value = stepRow.date;
stepDatePopup.open();
}
DateTime.DatePopup {
id: stepDatePopup
onAccepted: editModel.history.setDate(stepRow.index, value)
}
}
QQC2.ToolButton {
icon.name: "list-remove"
text: i18nc("@action:button", "Remove Step")
display: QQC2.AbstractButton.IconOnly
enabled: editModel.history.count > 1
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
onClicked: editModel.history.removeStep(stepRow.index)
}
}
}
QQC2.Button {
icon.name: "list-add"
text: i18nc("@action:button", "Add Step")
onClicked: editModel.history.appendStep()
}
}
ColumnLayout {
visible: rowRepeater.count > 0
Layout.fillWidth: true
Layout.leftMargin: Kirigami.Units.largeSpacing
Layout.rightMargin: Kirigami.Units.largeSpacing
@@ -204,11 +277,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 +352,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();
}
}
}
}
+40
View File
@@ -25,6 +25,46 @@ 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 {
AutoGhost.run();
jobsModel.refresh();
root.showDashboard();
}
}
// Applications with no response moved to Ghosted (at startup, after a
// database switch, or after the Preferences setting changed).
Connections {
target: AutoGhost
function onRan(count: int): void {
if (count > 0) {
jobsModel.refresh();
root.announceGhosted(count);
}
}
}
function announceGhosted(count: int): void {
root.showPassiveNotification(i18ncp("@info", "Marked %1 application with no response as Ghosted", "Marked %1 applications with no response as Ghosted", count));
}
DatabaseSetupDialog {
id: setupDialog
}
Component.onCompleted: {
if (DatabaseLocation.setupPending) {
setupDialog.open();
} else if (AutoGhost.lastRunCount > 0) {
root.announceGhosted(AutoGhost.lastRunCount);
}
}
// The job currently open in the edit form, so the sidebar can keep it highlighted.
property int currentJobId: -1
+141
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,145 @@ 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()
}
}
}
Kirigami.ListSectionHeader {
Layout.fillWidth: true
text: i18nc("@title:group", "Applications")
}
ColumnLayout {
Layout.fillWidth: true
Layout.leftMargin: Kirigami.Units.largeSpacing
Layout.rightMargin: Kirigami.Units.largeSpacing
Layout.topMargin: Kirigami.Units.smallSpacing
Layout.bottomMargin: Kirigami.Units.smallSpacing
spacing: Kirigami.Units.smallSpacing
QQC2.Switch {
id: autoGhostSwitch
Layout.fillWidth: true
text: i18nc("@option:check", "Mark applications with no response as Ghosted")
checked: AutoGhost.enabled
onToggled: AutoGhost.enabled = checked
}
RowLayout {
Layout.fillWidth: true
enabled: autoGhostSwitch.checked
spacing: Kirigami.Units.smallSpacing
QQC2.Label {
text: i18nc("@label:spinbox Mark as ghosted after [N] days", "After")
}
QQC2.SpinBox {
from: 1
to: 365
value: AutoGhost.days
onValueModified: AutoGhost.days = value
}
QQC2.Label {
Layout.fillWidth: true
text: i18nc("@label:spinbox Mark as ghosted after [N] days", "days without any activity")
wrapMode: Text.Wrap
}
}
QQC2.Label {
Layout.fillWidth: true
text: i18nc("@info", "Only applications still at Applied are affected. The move is recorded in their history, and you can change the stage back at any time.")
wrapMode: Text.Wrap
font: Kirigami.Theme.smallFont
opacity: 0.7
}
}
}
Component.onCompleted: DatabaseLocation.clearMessages()
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},
});
+156
View File
@@ -0,0 +1,156 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "stagehistorymodel.h"
#include "jobstage.h"
#include <algorithm>
using namespace Qt::Literals::StringLiterals;
namespace
{
/// Midday local time, so the calendar date survives time-zone conversion.
QDateTime atMidday(const QDate &date)
{
return QDateTime(date, QTime(12, 0)).toUTC();
}
QString nextStage(const QString &stage)
{
static const QStringList funnel{u"Applied"_s, u"Screening"_s, u"Interview"_s, u"Onsite"_s, u"Offer"_s, u"Accepted"_s};
if (JobStage::isTerminal(stage)) {
return u"Screening"_s; // a closed application reopening
}
const int index = funnel.indexOf(stage);
return funnel.value(index + 1, u"Rejected"_s);
}
}
StageHistoryModel::StageHistoryModel(QObject *parent)
: QAbstractListModel(parent)
{
}
int StageHistoryModel::rowCount(const QModelIndex &parent) const
{
return parent.isValid() ? 0 : m_steps.size();
}
QVariant StageHistoryModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_steps.size()) {
return {};
}
const StageStep &step = m_steps.at(index.row());
switch (role) {
case StageRole:
return step.stage;
case DateRole:
return step.at.toLocalTime().date();
default:
return {};
}
}
QHash<int, QByteArray> StageHistoryModel::roleNames() const
{
return {{StageRole, "stage"}, {DateRole, "date"}};
}
QStringList StageHistoryModel::stages() const
{
return JobStage::canonicalStages();
}
void StageHistoryModel::load(const QList<StageStep> &steps)
{
beginResetModel();
m_steps = steps;
std::stable_sort(m_steps.begin(), m_steps.end(), [](const StageStep &a, const StageStep &b) {
return a.at < b.at;
});
endResetModel();
m_edited = false;
Q_EMIT countChanged();
}
QList<StageStep> StageHistoryModel::steps() const
{
return m_steps;
}
bool StageHistoryModel::isEdited() const
{
return m_edited;
}
void StageHistoryModel::setStage(int row, const QString &stage)
{
if (row < 0 || row >= m_steps.size() || !JobStage::isValid(stage) || m_steps.at(row).stage == stage) {
return;
}
m_steps[row].stage = JobStage::canonical(stage);
Q_EMIT dataChanged(index(row), index(row), {StageRole});
markEdited();
}
void StageHistoryModel::setDate(int row, const QDateTime &when)
{
if (row < 0 || row >= m_steps.size() || !when.isValid()) {
return;
}
const QDate date = when.toLocalTime().date();
if (m_steps.at(row).at.toLocalTime().date() == date) {
return;
}
m_steps[row].at = atMidday(date);
sortByDate();
markEdited();
}
void StageHistoryModel::appendStep()
{
const QString stage = m_steps.isEmpty() ? u"Applied"_s : nextStage(m_steps.last().stage);
// Never earlier than the current last step, so the new step stays last.
QDateTime at = atMidday(QDate::currentDate());
if (!m_steps.isEmpty() && m_steps.last().at > at) {
at = m_steps.last().at;
}
beginInsertRows({}, m_steps.size(), m_steps.size());
m_steps.append({stage, at});
endInsertRows();
Q_EMIT countChanged();
markEdited();
}
void StageHistoryModel::removeStep(int row)
{
if (row < 0 || row >= m_steps.size() || m_steps.size() <= 1) {
return;
}
beginRemoveRows({}, row, row);
m_steps.removeAt(row);
endRemoveRows();
Q_EMIT countChanged();
markEdited();
}
void StageHistoryModel::sortByDate()
{
beginResetModel();
std::stable_sort(m_steps.begin(), m_steps.end(), [](const StageStep &a, const StageStep &b) {
return a.at < b.at;
});
endResetModel();
}
void StageHistoryModel::markEdited()
{
m_edited = true;
Q_EMIT edited();
}
+70
View File
@@ -0,0 +1,70 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once
#include "job.h"
#include <QAbstractListModel>
#include <QQmlEngine>
/**
* The editable stage history of one application, as shown in the edit
* form's Pipeline section: one row per step (stage + date), kept in date
* order. The last step is the application's current stage, and the first
* step's date is its date applied. JobEditModel owns one and saves it with
* JobsDatabase::replaceStageHistory().
*/
class StageHistoryModel : public QAbstractListModel
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Owned by JobEditModel")
Q_PROPERTY(int count READ rowCount NOTIFY countChanged)
Q_PROPERTY(QStringList stages READ stages CONSTANT)
public:
enum Roles {
StageRole = Qt::UserRole + 1,
DateRole,
};
Q_ENUM(Roles)
explicit StageHistoryModel(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role) const override;
QHash<int, QByteArray> roleNames() const override;
QStringList stages() const;
/// Replaces every step without marking the history as edited.
void load(const QList<StageStep> &steps);
QList<StageStep> steps() const;
bool isEdited() const;
Q_INVOKABLE void setStage(int row, const QString &stage);
/// Takes a JS Date from QML; only its (local) calendar date is kept.
Q_INVOKABLE void setDate(int row, const QDateTime &when);
/// Appends a step dated today, defaulting to the stage that usually
/// comes next.
Q_INVOKABLE void appendStep();
/// Removes a step; the last remaining step can't be removed.
Q_INVOKABLE void removeStep(int row);
Q_SIGNALS:
void countChanged();
/// Emitted after any change made through the invokables.
void edited();
private:
void sortByDate();
void markEdited();
QList<StageStep> m_steps;
bool m_edited = false;
};
+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();
}