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

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019FXfh48mASRsE1WEhy9jcM
2026-08-31 18:51:49 -05:00
austin 078343ff96 v0.1.2: Preferences page, About page, full icon set, redesigned edit form
- Rework the add/edit form onto a generic, catalog-driven field model
  (JobFieldCatalog + JobEditModel), replacing the old hand-written
  ApplicationEditDialog with ApplicationEditPage
- Add a Preferences page (reachable from the hamburger menu) with a
  KColorSchemeManager-backed color scheme switcher
- Add an About page reading from KAboutData
- Ship a full hicolor icon set (16-128px + scalable) via ecm_install_icons
  instead of a single flat SVG
- Restyle the edit form after KDE System Settings' Audio page:
  Kirigami.ListSectionHeader per category, flat separated rows, no card
  borders
- Explicitly declare Kirigami/KirigamiAddons/ColorScheme as CMake
  dependencies instead of relying on the QML import scanner alone
- Add KDEClangFormat/KDEGitCommitHooks, a CMakePresets.json, and a
  REUSE + clang-format lint CI workflow
- Reformat SPDX headers to include copyright, remove debug screenshot
  scaffolding
2026-07-03 16:25:42 -05:00
66 changed files with 5023 additions and 533 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
+16
View File
@@ -0,0 +1,16 @@
<!--
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: CC0-1.0
-->
## Reason for the change
<!-- What problem does this solve, or what does it add? -->
## Test plan
<!-- How did you verify this? Build/run steps, commands, etc. -->
## Screenshots
<!-- If this changes UI, include a before/after. -->
+2
View File
@@ -1,3 +1,5 @@
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
#
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
name: Build and Publish Flatpak name: Build and Publish Flatpak
+41
View File
@@ -0,0 +1,41 @@
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
#
# SPDX-License-Identifier: GPL-3.0-or-later
name: Lint
on:
pull_request: {}
push:
branches:
- main
jobs:
reuse:
name: REUSE compliance
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# 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@v5
clang-format:
name: clang-format
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# 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: |
find src autotests -name '*.h' -o -name '*.cpp' | \
xargs clang-format --dry-run --Werror
+2
View File
@@ -1,3 +1,5 @@
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
#
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
name: Build and Test name: Build and Test
+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.
+29 -3
View File
@@ -1,9 +1,11 @@
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
#
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
# Kareer - a Kirigami/Qt job application tracker # Kareer - a Kirigami/Qt job application tracker
cmake_minimum_required(VERSION 3.16) cmake_minimum_required(VERSION 3.16)
project(kareer VERSION 0.1.1 LANGUAGES CXX) project(kareer VERSION 0.1.5 LANGUAGES CXX)
set(REQUIRED_QT_VERSION 6.6.0) set(REQUIRED_QT_VERSION 6.6.0)
set(REQUIRED_KF_VERSION 6.5.0) set(REQUIRED_KF_VERSION 6.5.0)
@@ -15,7 +17,10 @@ include(KDEInstallDirs)
include(KDECMakeSettings) include(KDECMakeSettings)
include(KDECompilerSettings NO_POLICY_SCOPE) include(KDECompilerSettings NO_POLICY_SCOPE)
include(ECMSetupVersion) include(ECMSetupVersion)
include(ECMInstallIcons)
include(FeatureSummary) include(FeatureSummary)
include(KDEClangFormat)
include(KDEGitCommitHooks)
set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -28,16 +33,36 @@ find_package(Qt6 ${REQUIRED_QT_VERSION} REQUIRED COMPONENTS
Widgets Widgets
Qml Qml
Quick Quick
# QtQuick.Shapes runtime QML module (SankeyDiagram.qml); configure-time
# guard only, nothing links against it.
QuickShapesPrivate
# QtQuick.Dialogs runtime QML module (DatabaseSetupDialog.qml,
# SettingsPage.qml); configure-time guard only, nothing links against it.
QuickDialogs2
QuickControls2 QuickControls2
Sql Sql
Test Test
) )
find_package(KF6 ${REQUIRED_KF_VERSION} REQUIRED COMPONENTS find_package(KF6 ${REQUIRED_KF_VERSION} REQUIRED COMPONENTS
Config
I18n I18n
CoreAddons CoreAddons
IconThemes IconThemes
Crash Crash
ColorScheme
)
find_package(KF6Kirigami ${REQUIRED_KF_VERSION} REQUIRED)
set_package_properties(KF6Kirigami PROPERTIES
TYPE REQUIRED
DESCRIPTION "KDE's next-gen UI framework"
)
find_package(KF6KirigamiAddons REQUIRED)
set_package_properties(KF6KirigamiAddons PROPERTIES
TYPE REQUIRED
DESCRIPTION "Add-on components for Kirigami"
) )
ecm_setup_version(${PROJECT_VERSION} ecm_setup_version(${PROJECT_VERSION}
@@ -45,6 +70,7 @@ ecm_setup_version(${PROJECT_VERSION}
VERSION_HEADER "${CMAKE_CURRENT_BINARY_DIR}/kareer-version.h" VERSION_HEADER "${CMAKE_CURRENT_BINARY_DIR}/kareer-version.h"
) )
add_subdirectory(icons)
add_subdirectory(src) add_subdirectory(src)
if(BUILD_TESTING) if(BUILD_TESTING)
@@ -55,11 +81,11 @@ install(PROGRAMS io.github.toservetheking.Kareer.desktop
DESTINATION ${KDE_INSTALL_APPDIR}) DESTINATION ${KDE_INSTALL_APPDIR})
install(FILES io.github.toservetheking.Kareer.metainfo.xml install(FILES io.github.toservetheking.Kareer.metainfo.xml
DESTINATION ${KDE_INSTALL_METAINFODIR}) DESTINATION ${KDE_INSTALL_METAINFODIR})
install(FILES icons/io.github.toservetheking.Kareer.svg
DESTINATION ${KDE_INSTALL_ICONDIR}/hicolor/scalable/apps)
install(FILES LICENSES/GPL-3.0-or-later.txt install(FILES LICENSES/GPL-3.0-or-later.txt
DESTINATION ${KDE_INSTALL_DATAROOTDIR}/licenses/${PROJECT_NAME}) DESTINATION ${KDE_INSTALL_DATAROOTDIR}/licenses/${PROJECT_NAME})
# Translations: add a po/ directory and re-enable ki18n_install(po) once present. # Translations: add a po/ directory and re-enable ki18n_install(po) once present.
kde_configure_git_pre_commit_hook(CHECKS CLANG-FORMAT)
feature_summary(WHAT ALL INCLUDE_QUIET_PACKAGES FATAL_ON_MISSING_REQUIRED_PACKAGES) feature_summary(WHAT ALL INCLUDE_QUIET_PACKAGES FATAL_ON_MISSING_REQUIRED_PACKAGES)
+20
View File
@@ -0,0 +1,20 @@
{
"version": 6,
"cmakeMinimumRequired": {
"major": 3,
"minor": 23,
"patch": 0
},
"configurePresets": [
{
"name": "ninja-dev",
"displayName": "Ninja (dev)",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug",
"BUILD_TESTING": "ON"
}
}
]
}
+2
View File
@@ -0,0 +1,2 @@
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: CC0-1.0
+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 (`add|list|show|update|stage|delete|stats|stages`) — run `./build/bin/kareer
--help` to see the subcommands. --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 ## Test
```sh ```sh
@@ -30,10 +46,12 @@ release builds.
## Conventions ## Conventions
<!-- REUSE-IgnoreStart -->
- Every source file starts with `SPDX-License-Identifier: GPL-3.0-or-later` - 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 (in whatever comment syntax fits the file type). Files that can't carry
an inline header — `.desktop`, `keys/*.asc` — are covered instead by an inline header — `.desktop`, `keys/*.asc` — are covered instead by
`REUSE.toml`. `REUSE.toml`.
<!-- REUSE-IgnoreEnd -->
- `src/` is intentionally flat: one class per concern, no `models/`, - `src/` is intentionally flat: one class per concern, no `models/`,
`controllers/`, or `viewmodels/` subfolders. `controllers/`, or `viewmodels/` subfolders.
- C++ backend classes are exposed to QML via `QML_ELEMENT`; QML views are - 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.
+69 -12
View File
@@ -15,12 +15,18 @@ applications without ever opening a window.
salary expectation, notes, contact, and the date applied salary expectation, notes, contact, and the date applied
- A fixed pipeline of stages (Applied, Screening, Interview, Onsite, - A fixed pipeline of stages (Applied, Screening, Interview, Onsite,
Offer, Accepted, Rejected, Withdrawn, Ghosted) with full history of 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 - A Sankey diagram of the whole pipeline, showing where applications
progress and where they drop off progress and where they drop off
- Dashboard summary stats: total applications, active count, offers, - Dashboard summary stats: total applications, active count, offers,
response rate 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 for scripting
## Command line usage ## Command line usage
@@ -49,6 +55,10 @@ kareer show 1
kareer update 1 --salary-max 175000 kareer update 1 --salary-max 175000
kareer stage 1 Interview 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) # Delete (requires --yes to actually happen)
kareer delete 1 --yes kareer delete 1 --yes
@@ -58,9 +68,23 @@ kareer stages
``` ```
Run `kareer <command> --help` for the full option list of any Run `kareer <command> --help` for the full option list of any
subcommand. Data lives in `$XDG_DATA_HOME/kareer/kareer.sqlite` subcommand.
(under Flatpak, that's sandboxed to the app's own data directory); set
`KAREER_DB_PATH` to point at a different file. ### 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 ### Wiring up a resume-builder tool
@@ -94,25 +118,50 @@ flatpak install --user ./io.github.toservetheking.Kareer.flatpak
[GitHub Release]: https://github.com/toservetheking/Kareer/releases [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 ## Building
Requires Qt 6, KDE Frameworks 6, Kirigami, Kirigami Addons and the Requires Qt 6, KDE Frameworks 6, Kirigami, Kirigami Addons and the
CMake toolchain. On Arch/CachyOS: CMake toolchain. On Arch/CachyOS:
```sh ```sh
sudo pacman -S --needed cmake extra-cmake-modules base-devel \ sudo pacman -S --needed cmake ninja extra-cmake-modules base-devel \
qt6-base qt6-declarative kirigami kirigami-addons \ qt6-base qt6-declarative vulkan-headers kirigami kirigami-addons \
ki18n kcoreaddons kiconthemes kcrash kitemmodels qqc2-desktop-style ki18n 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: Then:
```sh ```sh
cmake -B build -G Ninja cmake -B build
cmake --build build cmake --build build
./build/bin/kareer ./build/bin/kareer
``` ```
(If you have `ninja` installed, add `-G Ninja` to the configure step or
use the `ninja-dev` preset: `cmake --preset ninja-dev`.)
Run the tests with `ctest --test-dir build`. Run the tests with `ctest --test-dir build`.
## Architecture ## Architecture
@@ -128,8 +177,16 @@ Run the tests with `ctest --test-dir build`.
- `sankeymodel.{h,cpp}` - turns stage history into laid-out Sankey - `sankeymodel.{h,cpp}` - turns stage history into laid-out Sankey
geometry (node columns/stacking, ribbon SVG path data); QML only geometry (node columns/stacking, ribbon SVG path data); QML only
draws what this hands back. draws what this hands back.
- `jobfieldcatalog.{h,cpp}` - the static catalog of edit-form fields and categories.
- `jobeditmodel.{h,cpp}` - `QAbstractListModel`-backed edit-form state, built from the field catalog.
- `stagehistorymodel.{h,cpp}` - the editable stage history shown in the edit form's Pipeline section.
- `clicommands.{h,cpp}` - the `add`/`list`/`show`/`update`/`stage`/ - `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), - `qml/` - Kirigami UI: `ApplicationsPage` (list + search),
`ApplicationEditDialog` (add/edit/delete form), `DashboardPage` `ApplicationEditPage` (add/edit/delete form), `DashboardPage`
(stat cards + pipeline), `SankeyDiagram` (the renderer). (stat cards + pipeline), `SankeyDiagram` (the renderer),
`SettingsPage` (the Preferences page), `DatabaseSetupDialog`
(the first-run database choice).
+11
View File
@@ -7,6 +7,17 @@ SPDX-PackageDownloadLocation = "https://github.com/toservetheking/Kareer"
path = [ path = [
"io.github.toservetheking.Kareer.desktop", "io.github.toservetheking.Kareer.desktop",
"keys/*.asc", "keys/*.asc",
"icons/*.png",
] ]
SPDX-License-Identifier = "GPL-3.0-or-later" SPDX-License-Identifier = "GPL-3.0-or-later"
SPDX-FileCopyrightText = "ToServeTheKing <[email protected]>" SPDX-FileCopyrightText = "ToServeTheKing <[email protected]>"
[[annotations]]
path = [
"README.md",
"CONTRIBUTING.md",
"CLAUDE.md",
".gitignore",
]
SPDX-License-Identifier = "CC0-1.0"
SPDX-FileCopyrightText = "ToServeTheKing <[email protected]>"
+25
View File
@@ -1,3 +1,5 @@
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
#
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
add_executable(jobsdatabasetest add_executable(jobsdatabasetest
@@ -37,3 +39,26 @@ target_link_libraries(sankeylayouttest PRIVATE
) )
add_test(NAME sankeylayouttest COMMAND sankeylayouttest) 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"
+154 -3
View File
@@ -1,7 +1,14 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
#include "job.h" SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
#include "jobsdatabase.h"
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "jobsdatabase.h"
#include "job.h"
#include <QSqlDatabase>
#include <QSqlQuery>
#include <QTemporaryDir> #include <QTemporaryDir>
#include <QtTest> #include <QtTest>
@@ -139,6 +146,150 @@ private Q_SLOTS:
QVERIFY(!db.jobById(job.id).has_value()); QVERIFY(!db.jobById(job.id).has_value());
QVERIFY(db.stageTransitions().isEmpty()); 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) QTEST_GUILESS_MAIN(JobsDatabaseTest)
+505 -17
View File
@@ -1,10 +1,20 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "job.h" #include "job.h"
#include "jobsdatabase.h" #include "jobsdatabase.h"
#include "sankeymodel.h" #include "sankeymodel.h"
#include <QPointF>
#include <QRegularExpression>
#include <QSqlDatabase>
#include <QSqlQuery>
#include <QTemporaryDir> #include <QTemporaryDir>
#include <QtTest> #include <QtTest>
#include <limits>
#include <memory> #include <memory>
// SankeyModel always opens JobsDatabase::defaultPath(), so each test points // SankeyModel always opens JobsDatabase::defaultPath(), so each test points
@@ -13,6 +23,13 @@ class SankeyLayoutTest : public QObject
{ {
Q_OBJECT Q_OBJECT
// Defaults of SankeyModel::reload()/relayout(), which every test uses.
static constexpr qreal nodeWidth = 16.0;
static constexpr qreal rowPadding = 10.0;
// The label margin/floor baked into the layout (sankeymodel.cpp).
static constexpr qreal labelMargin = 8.0;
static constexpr qreal eps = 0.01;
private Q_SLOTS: private Q_SLOTS:
void init() void init()
{ {
@@ -25,23 +42,13 @@ private Q_SLOTS:
{ {
JobsDatabase db; JobsDatabase db;
auto makeJob = [&](const QString &stage) { makeJob(db, QStringLiteral("Applied"));
Job job; makeJob(db, QStringLiteral("Applied"));
job.company = QStringLiteral("Co"); makeJob(db, QStringLiteral("Screening"));
job.title = QStringLiteral("Title"); makeJob(db, QStringLiteral("Rejected"));
QVERIFY(db.addJob(job));
if (stage != QStringLiteral("Applied")) {
QVERIFY(db.setStage(job.id, stage));
}
};
makeJob(QStringLiteral("Applied"));
makeJob(QStringLiteral("Applied"));
makeJob(QStringLiteral("Screening"));
makeJob(QStringLiteral("Rejected"));
SankeyModel model; SankeyModel model;
model.relayout(600, 400); model.reload(600, 400);
QVERIFY(!model.isEmpty()); QVERIFY(!model.isEmpty());
@@ -80,13 +87,494 @@ private Q_SLOTS:
QVERIFY(db.isOpen()); QVERIFY(db.isOpen());
SankeyModel model; SankeyModel model;
model.relayout(400, 300); model.reload(400, 300);
QVERIFY(model.isEmpty()); QVERIFY(model.isEmpty());
QVERIFY(model.nodes().isEmpty()); QVERIFY(model.nodes().isEmpty());
QVERIFY(model.links().isEmpty()); QVERIFY(model.links().isEmpty());
} }
void geometryFitsViewport()
{
JobsDatabase db;
seedDensePipeline(db);
SankeyModel model;
for (const QSizeF size : {QSizeF(600, 300), QSizeF(600, 40)}) {
model.reload(size.width(), size.height());
QVERIFY(!model.isEmpty());
QHash<qreal, qreal> columnHeights;
QHash<qreal, int> columnCounts;
for (const QVariant &v : model.nodes()) {
const QVariantMap m = v.toMap();
const qreal x = m.value(QStringLiteral("x")).toReal();
const qreal y = m.value(QStringLiteral("y")).toReal();
const qreal w = m.value(QStringLiteral("width")).toReal();
const qreal h = m.value(QStringLiteral("height")).toReal();
QVERIFY(x >= -eps);
QVERIFY(x + w <= size.width() + eps);
QVERIFY(y >= -eps);
QVERIFY(y + h <= size.height() + eps);
columnHeights[x] += h;
columnCounts[x] += 1;
// The label rect must lie inside the viewport too.
const qreal labelWidth = m.value(QStringLiteral("labelWidth")).toReal();
if (m.value(QStringLiteral("labelOnRight")).toBool()) {
QVERIFY(x + w + labelMargin + labelWidth <= size.width() + eps);
} else {
QVERIFY(x - labelMargin - labelWidth >= -eps);
}
}
for (auto it = columnHeights.constBegin(); it != columnHeights.constEnd(); ++it) {
const qreal gaps = rowPadding * (columnCounts.value(it.key()) - 1);
QVERIFY(it.value() + gaps <= size.height() + eps);
}
}
}
void ribbonsStayInsideNodes()
{
JobsDatabase db;
seedDensePipeline(db);
SankeyModel model;
model.reload(600, 300);
QVERIFY(!model.isEmpty());
QHash<QString, QVariantMap> nodeByStage;
for (const QVariant &v : model.nodes()) {
const QVariantMap m = v.toMap();
nodeByStage.insert(m.value(QStringLiteral("stage")).toString(), m);
}
QHash<QString, qreal> outboundTotal;
QHash<QString, qreal> inboundTotal;
for (const QVariant &v : model.links()) {
const QVariantMap m = v.toMap();
const qreal thickness = m.value(QStringLiteral("thickness")).toReal();
QVERIFY(thickness > 0);
const QVariantMap fromNode = nodeByStage.value(m.value(QStringLiteral("fromStage")).toString());
const QVariantMap toNode = nodeByStage.value(m.value(QStringLiteral("toStage")).toString());
const qreal sourceY = m.value(QStringLiteral("sourceY")).toReal();
const qreal targetY = m.value(QStringLiteral("targetY")).toReal();
QVERIFY(sourceY >= fromNode.value(QStringLiteral("y")).toReal() - eps);
QVERIFY(sourceY + thickness <= fromNode.value(QStringLiteral("y")).toReal() + fromNode.value(QStringLiteral("height")).toReal() + eps);
QVERIFY(targetY >= toNode.value(QStringLiteral("y")).toReal() - eps);
QVERIFY(targetY + thickness <= toNode.value(QStringLiteral("y")).toReal() + toNode.value(QStringLiteral("height")).toReal() + eps);
outboundTotal[m.value(QStringLiteral("fromStage")).toString()] += thickness;
inboundTotal[m.value(QStringLiteral("toStage")).toString()] += thickness;
}
for (auto it = outboundTotal.constBegin(); it != outboundTotal.constEnd(); ++it) {
QVERIFY(it.value() <= nodeByStage.value(it.key()).value(QStringLiteral("height")).toReal() + eps);
}
for (auto it = inboundTotal.constBegin(); it != inboundTotal.constEnd(); ++it) {
QVERIFY(it.value() <= nodeByStage.value(it.key()).value(QStringLiteral("height")).toReal() + eps);
}
}
void 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;
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);
QHash<QString, int> links;
for (const QVariant &v : model.links()) {
const QVariantMap m = v.toMap();
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},
};
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);
}
}
}
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;
makeJob(db, QStringLiteral("Applied"));
makeJob(db, QStringLiteral("Applied"));
makeJob(db, QStringLiteral("Rejected"));
SankeyModel model;
model.reload(600, 300);
// Only Start, Applied and Rejected are present: their columns should
// spread evenly over the full width, not sit at canonical positions.
QList<qreal> xs;
for (const QVariant &v : model.nodes()) {
const qreal x = v.toMap().value(QStringLiteral("x")).toReal();
if (!xs.contains(x)) {
xs.append(x);
}
}
std::sort(xs.begin(), xs.end());
QCOMPARE(xs.size(), 3);
QVERIFY(qAbs(xs.at(0)) < eps);
QVERIFY(qAbs(xs.at(1) - (600 - nodeWidth) / 2.0) < eps);
QVERIFY(qAbs(xs.at(2) - (600 - nodeWidth)) < eps);
}
void caseVariantStageIsCanonicalized()
{
JobsDatabase db;
Job job;
job.company = QStringLiteral("Co");
job.title = QStringLiteral("Title");
QVERIFY(db.addJob(job));
QVERIFY(db.setStage(job.id, QStringLiteral("rejected")));
QCOMPARE(db.jobById(job.id)->stage, QStringLiteral("Rejected"));
SankeyModel model;
model.reload(600, 300);
QStringList stages;
for (const QVariant &v : model.nodes()) {
stages.append(v.toMap().value(QStringLiteral("stage")).toString());
}
QVERIFY(stages.contains(QStringLiteral("Rejected")));
QVERIFY(!stages.contains(QStringLiteral("rejected")));
// Pre-canonicalization rows already in the database are repaired the
// next time it is opened (migrate() normalization).
{
QSqlDatabase raw = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), QStringLiteral("fixup"));
raw.setDatabaseName(m_dir->path() + QStringLiteral("/test.sqlite"));
QVERIFY(raw.open());
QSqlQuery q(raw);
QVERIFY(q.exec(QStringLiteral("UPDATE jobs SET stage = 'ghosted'")));
QVERIFY(q.exec(QStringLiteral("UPDATE stage_history SET to_stage = 'ghosted' WHERE to_stage = 'Rejected'")));
raw.close();
}
QSqlDatabase::removeDatabase(QStringLiteral("fixup"));
JobsDatabase reopened;
QVERIFY(reopened.isOpen());
QCOMPARE(reopened.jobById(job.id)->stage, QStringLiteral("Ghosted"));
const auto transitions = reopened.stageTransitions();
for (const StageTransition &t : transitions) {
QVERIFY(t.toStage != QStringLiteral("ghosted"));
}
}
void degenerateSizesDoNotCrash()
{
JobsDatabase db;
seedDensePipeline(db);
SankeyModel model;
model.reload(0, 0);
QVERIFY(model.isEmpty());
model.relayout(-5, 100);
QVERIFY(model.isEmpty());
model.relayout(5, 5);
model.relayout(2000, 2);
model.relayout(600, 300);
QVERIFY(!model.isEmpty());
}
private: private:
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;
job.company = QStringLiteral("Co");
job.title = QStringLiteral("Title");
QVERIFY(db.addJob(job));
if (stage != QStringLiteral("Applied")) {
QVERIFY(db.setStage(job.id, stage));
}
}
static void walkJob(JobsDatabase &db, const QStringList &stages)
{
Job job;
job.company = QStringLiteral("Co");
job.title = QStringLiteral("Title");
QVERIFY(db.addJob(job));
for (const QString &stage : stages) {
QVERIFY(db.setStage(job.id, stage));
}
}
/// Populates every column, including all four terminal outcomes sharing
/// the last one.
static void seedDensePipeline(JobsDatabase &db)
{
walkJob(db, {QStringLiteral("Screening"), QStringLiteral("Interview"), QStringLiteral("Onsite"), QStringLiteral("Offer"), QStringLiteral("Accepted")});
walkJob(db, {QStringLiteral("Screening"), QStringLiteral("Rejected")});
walkJob(db, {QStringLiteral("Ghosted")});
walkJob(db, {QStringLiteral("Screening"), QStringLiteral("Interview"), QStringLiteral("Rejected")});
walkJob(db, {QStringLiteral("Withdrawn")});
walkJob(db, {QStringLiteral("Screening"), QStringLiteral("Interview"), QStringLiteral("Onsite"), QStringLiteral("Rejected")});
walkJob(db, {});
walkJob(db, {});
walkJob(db, {});
walkJob(db, {});
}
std::unique_ptr<QTemporaryDir> m_dir; std::unique_ptr<QTemporaryDir> m_dir;
}; };
+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.4
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=('e73e7a552bca68fc3f869c96efc607f0862564b3e5f99a505ff33b618bc5cb71')
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.

After

Width:  |  Height:  |  Size: 758 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+18
View File
@@ -0,0 +1,18 @@
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
#
# 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.
ecm_install_icons(ICONS
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
)
-25
View File
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- 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

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]> -->
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!--
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.
-->
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<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.0 KiB

@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]> -->
<!-- SPDX-License-Identifier: CC0-1.0 --> <!-- SPDX-License-Identifier: CC0-1.0 -->
<component type="desktop-application"> <component type="desktop-application">
<id>io.github.toservetheking.Kareer</id> <id>io.github.toservetheking.Kareer</id>
@@ -64,6 +65,50 @@
<content_rating type="oars-1.1"/> <content_rating type="oars-1.1"/>
<releases> <releases>
<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>
<li>Redesign the Sankey pipeline layout: top-aligned funnel, columns spread across the full width, labels always visible</li>
<li>Order ribbons at each stage to stop them braiding over each other</li>
<li>Canonicalize stage names written via the CLI (e.g. "rejected" now maps to "Rejected") and repair existing databases</li>
<li>Debounce window-resize relayouts and stop re-reading the database on resize</li>
</ul>
</description>
</release>
<release version="0.1.2" date="2026-07-03">
<description>
<ul>
<li>Rework the add/edit form onto a generic, catalog-driven field model</li>
<li>Add a Preferences page with a color-scheme switcher</li>
<li>Add an About page</li>
<li>Ship a full hicolor icon set instead of a single scalable SVG</li>
<li>Match KDE System Settings' Audio page layout for the edit form</li>
</ul>
</description>
</release>
<release version="0.1.1" date="2026-07-03"> <release version="0.1.1" date="2026-07-03">
<description> <description>
<ul> <ul>
+16 -11
View File
@@ -1,3 +1,5 @@
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
#
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
id: io.github.toservetheking.Kareer id: io.github.toservetheking.Kareer
runtime: org.kde.Platform runtime: org.kde.Platform
@@ -6,9 +8,12 @@ runtime-version: '6.10'
sdk: org.kde.Sdk sdk: org.kde.Sdk
command: kareer command: kareer
# A job application tracker: no host filesystem, network, or portal access is # A job application tracker: no host filesystem or network access is needed.
# needed. Everything it stores lives in the app's own XDG data directory, # By default everything lives in the app's own XDG data directory, which
# which Flatpak grants by default. # 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: finish-args:
- --share=ipc - --share=ipc
- --socket=fallback-x11 - --socket=fallback-x11
@@ -27,12 +32,12 @@ modules:
- -DCMAKE_BUILD_TYPE=Release - -DCMAKE_BUILD_TYPE=Release
run-tests: true run-tests: true
sources: sources:
# For Flathub / release builds, pin to a tag AND commit: # For local testing before the repo is pushed:
- type: git - type: dir
url: https://github.com/toservetheking/Kareer.git path: .
tag: v0.1.1
# commit: <fill in the exact commit SHA the tag points at>
# #
# For local testing before the repo is pushed, replace the source above with: # For Flathub / release builds, pin to a tag AND commit instead:
# - type: dir # - type: git
# path: . # url: https://github.com/toservetheking/Kareer.git
# tag: v0.1.5
# commit: <fill in the exact commit SHA the tag points at>
+27 -1
View File
@@ -1,9 +1,12 @@
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
#
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
add_executable(kareer add_executable(kareer
main.cpp main.cpp
jobstage.cpp jobstage.cpp
jobsdatabase.cpp jobsdatabase.cpp
jobfieldcatalog.cpp
clicommands.cpp clicommands.cpp
) )
@@ -14,9 +17,11 @@ qt_add_qml_module(kareer
QML_FILES QML_FILES
qml/Main.qml qml/Main.qml
qml/ApplicationsPage.qml qml/ApplicationsPage.qml
qml/ApplicationEditDialog.qml qml/ApplicationEditPage.qml
qml/DashboardPage.qml qml/DashboardPage.qml
qml/SankeyDiagram.qml qml/SankeyDiagram.qml
qml/SettingsPage.qml
qml/DatabaseSetupDialog.qml
SOURCES SOURCES
jobsmodel.cpp jobsmodel.cpp
jobsmodel.h jobsmodel.h
@@ -24,6 +29,24 @@ qt_add_qml_module(kareer
statsmodel.h statsmodel.h
sankeymodel.cpp sankeymodel.cpp
sankeymodel.h 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}) target_include_directories(kareer PRIVATE ${CMAKE_BINARY_DIR})
@@ -36,11 +59,14 @@ target_link_libraries(kareer PRIVATE
Qt6::Quick Qt6::Quick
Qt6::QuickControls2 Qt6::QuickControls2
Qt6::Sql Qt6::Sql
KF6::ConfigCore
KF6::I18n KF6::I18n
KF6::I18nQml KF6::I18nQml
KF6::CoreAddons KF6::CoreAddons
KF6::IconThemes KF6::IconThemes
KF6::Crash KF6::Crash
KF6::ColorScheme
KF6::Kirigami
) )
install(TARGETS kareer ${KDE_INSTALL_TARGETS_DEFAULT_ARGS}) install(TARGETS kareer ${KDE_INSTALL_TARGETS_DEFAULT_ARGS})
+34
View File
@@ -0,0 +1,34 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "appcolorscheme.h"
#include <KColorSchemeManager>
AppColorScheme::AppColorScheme(QObject *parent)
: QObject(parent)
, mSchemes(KColorSchemeManager::instance())
{
}
QAbstractItemModel *AppColorScheme::colorSchemesModel()
{
return mSchemes->model();
}
QString AppColorScheme::activeColorSchemeName() const
{
return mSchemes->activeSchemeName();
}
void AppColorScheme::setActiveColorSchemeName(const QString &name)
{
if (name == activeColorSchemeName()) {
return;
}
mSchemes->activateScheme(mSchemes->indexForScheme(name));
Q_EMIT activeColorSchemeNameChanged();
}
+40
View File
@@ -0,0 +1,40 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once
#include <QAbstractItemModel>
#include <QObject>
#include <QQmlEngine>
class KColorSchemeManager;
/**
* Thin QML-facing wrapper around KColorSchemeManager: exposes the list of
* installed color schemes and the currently active one. KColorSchemeManager
* autosaves the active scheme itself, so there is nothing else to persist.
*/
class AppColorScheme : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
Q_PROPERTY(QAbstractItemModel *colorSchemesModel READ colorSchemesModel CONSTANT)
Q_PROPERTY(QString activeColorSchemeName READ activeColorSchemeName WRITE setActiveColorSchemeName NOTIFY activeColorSchemeNameChanged)
public:
explicit AppColorScheme(QObject *parent = nullptr);
QAbstractItemModel *colorSchemesModel();
QString activeColorSchemeName() const;
void setActiveColorSchemeName(const QString &name);
Q_SIGNALS:
void activeColorSchemeNameChanged();
private:
KColorSchemeManager *mSchemes;
};
+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;
};
+109 -14
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "clicommands.h" #include "clicommands.h"
#include "job.h" #include "job.h"
@@ -21,6 +26,13 @@ using namespace Qt::Literals::StringLiterals;
namespace 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) QJsonValue optionalInt(int value)
{ {
return value < 0 ? QJsonValue() : QJsonValue(value); return value < 0 ? QJsonValue() : QJsonValue(value);
@@ -154,6 +166,7 @@ int runAdd(const QString &program, const QStringList &args)
QCommandLineParser parser; QCommandLineParser parser;
parser.setApplicationDescription(u"Add a new job application"_s); parser.setApplicationDescription(u"Add a new job application"_s);
parser.addHelpOption(); parser.addHelpOption();
addDbOption(parser);
addCommonJobOptions(parser); addCommonJobOptions(parser);
parser.process(QStringList{program} + args); parser.process(QStringList{program} + args);
@@ -220,6 +233,7 @@ int runList(const QString &program, const QStringList &args)
QCommandLineParser parser; QCommandLineParser parser;
parser.setApplicationDescription(u"List job applications"_s); parser.setApplicationDescription(u"List job applications"_s);
parser.addHelpOption(); parser.addHelpOption();
addDbOption(parser);
parser.addOption({u"stage"_s, u"Filter by stage"_s, u"stage"_s}); 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"company"_s, u"Filter by company (substring match)"_s, u"text"_s});
parser.addOption({u"json"_s, u"Print machine-readable JSON"_s}); parser.addOption({u"json"_s, u"Print machine-readable JSON"_s});
@@ -267,6 +281,7 @@ int runShow(const QString &program, const QStringList &args)
QCommandLineParser parser; QCommandLineParser parser;
parser.setApplicationDescription(u"Show one job application"_s); parser.setApplicationDescription(u"Show one job application"_s);
parser.addHelpOption(); parser.addHelpOption();
addDbOption(parser);
parser.addOption({u"json"_s, u"Print machine-readable JSON"_s}); parser.addOption({u"json"_s, u"Print machine-readable JSON"_s});
parser.addPositionalArgument(u"id"_s, u"Application id"_s); parser.addPositionalArgument(u"id"_s, u"Application id"_s);
parser.process(QStringList{program} + args); parser.process(QStringList{program} + args);
@@ -305,6 +320,7 @@ int runUpdate(const QString &program, const QStringList &args)
QCommandLineParser parser; QCommandLineParser parser;
parser.setApplicationDescription(u"Update fields on an existing application"_s); parser.setApplicationDescription(u"Update fields on an existing application"_s);
parser.addHelpOption(); parser.addHelpOption();
addDbOption(parser);
addCommonJobOptions(parser); addCommonJobOptions(parser);
parser.addPositionalArgument(u"id"_s, u"Application id"_s); parser.addPositionalArgument(u"id"_s, u"Application id"_s);
parser.process(QStringList{program} + args); parser.process(QStringList{program} + args);
@@ -405,6 +421,7 @@ int runStage(const QString &program, const QStringList &args)
QCommandLineParser parser; QCommandLineParser parser;
parser.setApplicationDescription(u"Move an application to a new stage"_s); parser.setApplicationDescription(u"Move an application to a new stage"_s);
parser.addHelpOption(); parser.addHelpOption();
addDbOption(parser);
parser.addOption({u"json"_s, u"Print machine-readable JSON"_s}); parser.addOption({u"json"_s, u"Print machine-readable JSON"_s});
parser.addPositionalArgument(u"id"_s, u"Application id"_s); parser.addPositionalArgument(u"id"_s, u"Application id"_s);
parser.addPositionalArgument(u"stage"_s, u"New stage: %1"_s.arg(JobStage::canonicalStages().join(u", "_s))); parser.addPositionalArgument(u"stage"_s, u"New stage: %1"_s.arg(JobStage::canonicalStages().join(u", "_s)));
@@ -438,7 +455,7 @@ int runStage(const QString &program, const QStringList &args)
if (parser.isSet(u"json"_s)) { if (parser.isSet(u"json"_s)) {
printJson(jobToJson(*job)); printJson(jobToJson(*job));
} else { } else {
QTextStream(stdout) << u"Application #%1 moved to %2"_s.arg(id).arg(stage) << Qt::endl; QTextStream(stdout) << u"Application #%1 moved to %2"_s.arg(id).arg(job->stage) << Qt::endl;
} }
return 0; return 0;
} }
@@ -448,6 +465,7 @@ int runDelete(const QString &program, const QStringList &args)
QCommandLineParser parser; QCommandLineParser parser;
parser.setApplicationDescription(u"Delete an application"_s); parser.setApplicationDescription(u"Delete an application"_s);
parser.addHelpOption(); parser.addHelpOption();
addDbOption(parser);
parser.addOption({u"yes"_s, u"Confirm deletion"_s}); parser.addOption({u"yes"_s, u"Confirm deletion"_s});
parser.addPositionalArgument(u"id"_s, u"Application id"_s); parser.addPositionalArgument(u"id"_s, u"Application id"_s);
parser.process(QStringList{program} + args); parser.process(QStringList{program} + args);
@@ -485,6 +503,7 @@ int runStats(const QString &program, const QStringList &args)
QCommandLineParser parser; QCommandLineParser parser;
parser.setApplicationDescription(u"Summary statistics across all applications"_s); parser.setApplicationDescription(u"Summary statistics across all applications"_s);
parser.addHelpOption(); parser.addHelpOption();
addDbOption(parser);
parser.addOption({u"json"_s, u"Print machine-readable JSON"_s}); parser.addOption({u"json"_s, u"Print machine-readable JSON"_s});
parser.process(QStringList{program} + args); parser.process(QStringList{program} + args);
@@ -521,11 +540,80 @@ int runStats(const QString &program, const QStringList &args)
return 0; 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) int runStages(const QString &program, const QStringList &args)
{ {
QCommandLineParser parser; QCommandLineParser parser;
parser.setApplicationDescription(u"List the canonical pipeline stages"_s); parser.setApplicationDescription(u"List the canonical pipeline stages"_s);
parser.addHelpOption(); parser.addHelpOption();
addDbOption(parser);
parser.addOption({u"json"_s, u"Print machine-readable JSON"_s}); parser.addOption({u"json"_s, u"Print machine-readable JSON"_s});
parser.process(QStringList{program} + args); parser.process(QStringList{program} + args);
@@ -548,18 +636,13 @@ int runStages(const QString &program, const QStringList &args)
int runHelp() int runHelp()
{ {
QTextStream out(stdout); QTextStream out(stdout);
out << u"Usage: kareer <command> [options]\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"Commands:\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" add Add a new job 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" list List job applications\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" show Show one job application\n"_s
<< u" update Update fields on an existing application\n"_s
<< u" stage Move an application to a new stage\n"_s
<< u" delete Delete an application\n"_s
<< u" stats Summary statistics\n"_s
<< u" stages List the canonical pipeline stages\n\n"_s
<< u"Run 'kareer <command> --help' for the options of a specific command.\n"_s << u"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; return 0;
} }
@@ -568,7 +651,16 @@ int runHelp()
bool Cli::isSubcommand(const QString &arg) bool Cli::isSubcommand(const QString &arg)
{ {
static const QSet<QString> subcommands{ 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); return subcommands.contains(arg);
} }
@@ -604,5 +696,8 @@ int Cli::run(QCoreApplication &app)
if (subcommand == u"stages"_s) { if (subcommand == u"stages"_s) {
return runStages(program, rest); return runStages(program, rest);
} }
if (subcommand == u"history"_s) {
return runHistory(program, rest);
}
return runHelp(); return runHelp();
} }
+7 -2
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once #pragma once
#include <QString> #include <QString>
@@ -6,7 +11,7 @@
class QCoreApplication; 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 * Lets other tools (a resume generator, a shell script) log and query
* applications without ever starting the Kirigami GUI. * 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;
};
+12 -1
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once #pragma once
#include <QDate> #include <QDate>
@@ -26,6 +31,12 @@ struct Job {
QDateTime updatedAt; 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 /// One recorded move from one stage to another (or from "Start" for the
/// initial application), used to build the Sankey diagram. /// initial application), used to build the Sankey diagram.
struct StageTransition { struct StageTransition {
+292
View File
@@ -0,0 +1,292 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "jobeditmodel.h"
#include <KLocalizedString>
#include <QDate>
using namespace Qt::Literals::StringLiterals;
using JobFieldCatalog::ComboRow;
using JobFieldCatalog::DateRow;
using JobFieldCatalog::Field;
using JobFieldCatalog::SpinBoxRow;
using JobFieldCatalog::TextAreaRow;
using JobFieldCatalog::TextRow;
namespace
{
/// The three salary fields share the "0 shown in the UI means unset, -1 stored" convention.
bool isSalaryField(const QString &id)
{
return id == QLatin1String("salaryMin") || id == QLatin1String("salaryMax") || id == QLatin1String("salaryExpectation");
}
}
JobEditModel::JobEditModel(QObject *parent)
: QAbstractListModel(parent)
, m_history(new StageHistoryModel(this))
, m_fields(JobFieldCatalog::fields())
{
resetValues();
}
int JobEditModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) {
return 0;
}
return m_fields.size();
}
QVariant JobEditModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_fields.size()) {
return {};
}
const Field &field = m_fields.at(index.row());
switch (role) {
case FieldIdRole:
return field.id;
case CategoryIdRole:
return field.categoryId;
case LabelRole:
return field.label;
case RowTypeRole:
return static_cast<int>(field.rowType);
case ValueRole:
return m_values.value(field.id);
case ComboOptionsRole:
return field.comboOptions;
case PlaceholderRole:
return field.placeholder;
case SpinMinRole:
return field.spinMin;
case SpinMaxRole:
return field.spinMax;
default:
return {};
}
}
QHash<int, QByteArray> JobEditModel::roleNames() const
{
return {
{FieldIdRole, "fieldId"},
{CategoryIdRole, "categoryId"},
{LabelRole, "label"},
{RowTypeRole, "rowType"},
{ValueRole, "value"},
{ComboOptionsRole, "comboOptions"},
{PlaceholderRole, "placeholder"},
{SpinMinRole, "spinMin"},
{SpinMaxRole, "spinMax"},
};
}
JobsModel *JobEditModel::jobsModel() const
{
return m_jobsModel;
}
void JobEditModel::setJobsModel(JobsModel *model)
{
if (m_jobsModel == model) {
return;
}
m_jobsModel = model;
// 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();
}
int JobEditModel::editingJobId() const
{
return m_editingJobId;
}
void JobEditModel::setEditingJobId(int id)
{
if (m_editingJobId == id) {
return;
}
m_editingJobId = id;
resetValues();
Q_EMIT editingJobIdChanged();
}
void JobEditModel::resetValues()
{
m_values.clear();
if (m_editingJobId < 0 || !m_jobsModel) {
m_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"remoteType"_s] = u"Unspecified"_s;
m_values[u"salaryMin"_s] = 0;
m_values[u"salaryMax"_s] = 0;
m_values[u"salaryExpectation"_s] = 0;
} else {
const QVariantMap data = m_jobsModel->jobData(m_editingJobId);
for (const Field &field : m_fields) {
QVariant value = data.value(field.id);
if (field.id == u"remoteType"_s && value.toString().isEmpty()) {
value = u"Unspecified"_s;
}
if (isSalaryField(field.id) && value.toInt() < 0) {
value = 0;
}
m_values[field.id] = value;
}
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) {
Q_EMIT dataChanged(index(0), index(rowCount() - 1));
}
}
QVariantList JobEditModel::categories() const
{
QVariantList result;
for (const JobFieldCatalog::Category &category : JobFieldCatalog::categories()) {
result.append(QVariantMap{{u"id"_s, category.id}, {u"title"_s, category.title}});
}
return result;
}
QString JobEditModel::lastError() const
{
return m_lastError;
}
StageHistoryModel *JobEditModel::history() const
{
return m_history;
}
void JobEditModel::setValue(int row, const QVariant &value)
{
if (row < 0 || row >= m_fields.size()) {
return;
}
m_values[m_fields.at(row).id] = value;
Q_EMIT dataChanged(index(row), index(row), {ValueRole});
}
bool JobEditModel::save()
{
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;
}
QVariantMap fields;
for (auto it = m_values.constBegin(); it != m_values.constEnd(); ++it) {
fields.insert(it.key(), it.value());
}
if (fields.value(u"remoteType"_s).toString() == u"Unspecified"_s) {
fields[u"remoteType"_s] = QString();
}
for (const QString &id : {u"salaryMin"_s, u"salaryMax"_s, u"salaryExpectation"_s}) {
if (fields.value(id).toInt() <= 0) {
fields[id] = -1;
}
}
// 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 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()
{
if (!m_jobsModel || m_editingJobId < 0) {
return false;
}
return m_jobsModel->removeJob(m_editingJobId);
}
+90
View File
@@ -0,0 +1,90 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once
#include "jobfieldcatalog.h"
#include "jobsmodel.h"
#include "stagehistorymodel.h"
#include <QAbstractListModel>
#include <QHash>
#include <QQmlEngine>
#include <QVariant>
/**
* Drives the add/edit form the way FlatKontrol's PermissionsController drives
* PermissionsPage: a generic row model (one row per JobFieldCatalog::Field)
* that QML renders via Repeater + DelegateChooser on rowType, instead of
* each field being hand-written in QML.
*/
class JobEditModel : public QAbstractListModel
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(JobsModel *jobsModel READ jobsModel WRITE setJobsModel NOTIFY jobsModelChanged)
Q_PROPERTY(int editingJobId READ editingJobId WRITE setEditingJobId NOTIFY editingJobIdChanged)
Q_PROPERTY(QVariantList categories READ categories CONSTANT)
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
/// 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 {
FieldIdRole = Qt::UserRole + 1,
CategoryIdRole,
LabelRole,
RowTypeRole,
ValueRole,
ComboOptionsRole,
PlaceholderRole,
SpinMinRole,
SpinMaxRole,
};
Q_ENUM(Roles)
explicit JobEditModel(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role) const override;
QHash<int, QByteArray> roleNames() const override;
JobsModel *jobsModel() const;
void setJobsModel(JobsModel *model);
int editingJobId() const;
void setEditingJobId(int id);
QVariantList categories() const;
QString lastError() const;
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);
/// Persists the current values via jobsModel; true on success.
Q_INVOKABLE bool save();
Q_INVOKABLE bool deleteJob();
Q_SIGNALS:
void jobsModelChanged();
void editingJobIdChanged();
void lastErrorChanged();
private:
void resetValues();
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;
};
+55
View File
@@ -0,0 +1,55 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "jobfieldcatalog.h"
using namespace Qt::Literals::StringLiterals;
namespace JobFieldCatalog
{
QList<Category> categories()
{
return {
{u"company"_s, QStringLiteral("Company")},
{u"pipeline"_s, QStringLiteral("Pipeline")},
{u"salary"_s, QStringLiteral("Salary")},
{u"details"_s, QStringLiteral("Additional Details")},
};
}
QList<Field> fields()
{
return {
{u"company"_s, u"company"_s, QStringLiteral("Company:"), TextRow, {}, {}, 0, 0},
{u"title"_s, u"company"_s, QStringLiteral("Job Title:"), TextRow, {}, {}, 0, 0},
{u"location"_s, u"company"_s, QStringLiteral("Location:"), TextRow, {}, {}, 0, 0},
{u"remoteType"_s,
u"company"_s,
QStringLiteral("Remote Type:"),
ComboRow,
{QStringLiteral("Unspecified"), QStringLiteral("Onsite"), QStringLiteral("Hybrid"), QStringLiteral("Remote")},
{},
0,
0},
// 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},
{u"salaryExpectation"_s, u"salary"_s, QStringLiteral("Your Expectation:"), SpinBoxRow, {}, {}, 0, 5000000},
{u"currency"_s, u"salary"_s, QStringLiteral("Currency:"), TextRow, {}, {}, 0, 0},
{u"source"_s, u"details"_s, QStringLiteral("Source:"), TextRow, {}, QStringLiteral("Referral, LinkedIn, company site..."), 0, 0},
{u"url"_s, u"details"_s, QStringLiteral("Job Posting URL:"), TextRow, {}, {}, 0, 0},
{u"contact"_s, u"details"_s, QStringLiteral("Contact:"), TextRow, {}, {}, 0, 0},
{u"notes"_s, u"details"_s, QStringLiteral("Notes:"), TextAreaRow, {}, {}, 0, 0},
};
}
}
+47
View File
@@ -0,0 +1,47 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once
#include <QList>
#include <QString>
#include <QStringList>
/**
* The static structure of the job edit form: which categories exist, and
* which fields belong to each, in display order. Mirrors FlatKontrol's
* PermissionCatalog - JobEditModel supplies the per-job values, this
* supplies the shape.
*/
namespace JobFieldCatalog
{
enum RowType {
TextRow = 0,
ComboRow,
SpinBoxRow,
DateRow,
TextAreaRow,
};
struct Category {
QString id;
QString title;
};
struct Field {
QString id; ///< Matches a Job/JobsModel field key (see JobsModel::mapFromJob).
QString categoryId;
QString label;
RowType rowType;
QStringList comboOptions; ///< Only meaningful for ComboRow.
QString placeholder; ///< Only meaningful for TextRow.
int spinMin = 0; ///< Only meaningful for SpinBoxRow.
int spinMax = 0;
};
QList<Category> categories();
QList<Field> fields();
}
+289 -12
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "jobsdatabase.h" #include "jobsdatabase.h"
#include "jobstage.h" #include "jobstage.h"
@@ -17,6 +22,10 @@ namespace
{ {
QAtomicInteger<int> s_connectionCounter{0}; QAtomicInteger<int> s_connectionCounter{0};
QString s_pathOverride;
QString s_configuredPath;
bool s_selectionPending = false;
QVariant salaryToVariant(int value) QVariant salaryToVariant(int value)
{ {
if (value < 0) { if (value < 0) {
@@ -33,6 +42,10 @@ int salaryFromVariant(const QVariant &value)
JobsDatabase::JobsDatabase() JobsDatabase::JobsDatabase()
{ {
if (s_selectionPending) {
m_lastError = u"No database selected"_s;
return;
}
init(defaultPath()); init(defaultPath());
} }
@@ -43,6 +56,14 @@ JobsDatabase::JobsDatabase(const QString &path)
JobsDatabase::~JobsDatabase() JobsDatabase::~JobsDatabase()
{ {
close();
}
void JobsDatabase::close()
{
if (m_connectionName.isEmpty()) {
return;
}
{ {
QSqlDatabase db = QSqlDatabase::database(m_connectionName, false); QSqlDatabase db = QSqlDatabase::database(m_connectionName, false);
if (db.isValid()) { if (db.isValid()) {
@@ -50,22 +71,103 @@ JobsDatabase::~JobsDatabase()
} }
} }
QSqlDatabase::removeDatabase(m_connectionName); QSqlDatabase::removeDatabase(m_connectionName);
m_connectionName.clear();
m_path.clear();
} }
QString JobsDatabase::defaultPath() QString JobsDatabase::defaultPath()
{ {
const QString overridePath = qEnvironmentVariable("KAREER_DB_PATH"); if (!s_pathOverride.isEmpty()) {
if (!overridePath.isEmpty()) { return s_pathOverride;
return overridePath;
} }
const QString dir = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + u"/kareer"_s; const QString envPath = qEnvironmentVariable("KAREER_DB_PATH");
QDir().mkpath(dir); if (!envPath.isEmpty()) {
return dir + u"/kareer.sqlite"_s; 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) void JobsDatabase::init(const QString &path)
{ {
m_connectionName = u"kareer_conn_%1"_s.arg(s_connectionCounter.fetchAndAddRelaxed(1)); m_connectionName = u"kareer_conn_%1"_s.arg(s_connectionCounter.fetchAndAddRelaxed(1));
m_path = path;
QDir().mkpath(QFileInfo(path).absolutePath()); QDir().mkpath(QFileInfo(path).absolutePath());
@@ -127,6 +229,26 @@ bool JobsDatabase::migrate()
return false; return false;
} }
// Rows written before stages were canonicalized on write (e.g.
// "rejected" via the CLI) would otherwise show up as separate Sankey
// nodes with default column/color. lower() is ASCII-only, which is fine:
// the stage vocabulary is ASCII.
for (const QString &stage : JobStage::canonicalStages()) {
for (const QString &statement : {
u"UPDATE jobs SET stage = :s WHERE stage != :s AND lower(stage) = lower(:s)"_s,
u"UPDATE stage_history SET to_stage = :s WHERE to_stage != :s AND lower(to_stage) = lower(:s)"_s,
u"UPDATE stage_history SET from_stage = :s WHERE from_stage != :s AND lower(from_stage) = lower(:s)"_s,
}) {
QSqlQuery normalize(db);
normalize.prepare(statement);
normalize.bindValue(u":s"_s, stage);
if (!normalize.exec()) {
m_lastError = normalize.lastError().text();
return false;
}
}
}
return true; return true;
} }
@@ -166,6 +288,9 @@ Job JobsDatabase::jobFromQuery(QSqlQuery &query) const
QList<Job> JobsDatabase::allJobs() const QList<Job> JobsDatabase::allJobs() const
{ {
QList<Job> jobs; QList<Job> jobs;
if (!isOpen()) {
return jobs;
}
QSqlQuery query(QSqlDatabase::database(m_connectionName)); QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(u"SELECT * FROM jobs ORDER BY date_applied DESC, id DESC"_s); query.prepare(u"SELECT * FROM jobs ORDER BY date_applied DESC, id DESC"_s);
if (!query.exec()) { if (!query.exec()) {
@@ -179,6 +304,9 @@ QList<Job> JobsDatabase::allJobs() const
std::optional<Job> JobsDatabase::jobById(int id) const std::optional<Job> JobsDatabase::jobById(int id) const
{ {
if (!isOpen()) {
return std::nullopt;
}
QSqlQuery query(QSqlDatabase::database(m_connectionName)); QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(u"SELECT * FROM jobs WHERE id = :id"_s); query.prepare(u"SELECT * FROM jobs WHERE id = :id"_s);
query.bindValue(u":id"_s, id); query.bindValue(u":id"_s, id);
@@ -197,6 +325,7 @@ bool JobsDatabase::addJob(Job &job)
m_lastError = u"Unknown stage '%1'"_s.arg(job.stage); m_lastError = u"Unknown stage '%1'"_s.arg(job.stage);
return false; return false;
} }
job.stage = JobStage::canonical(job.stage);
QSqlDatabase db = QSqlDatabase::database(m_connectionName); QSqlDatabase db = QSqlDatabase::database(m_connectionName);
const QDateTime now = QDateTime::currentDateTimeUtc(); const QDateTime now = QDateTime::currentDateTimeUtc();
@@ -204,7 +333,8 @@ bool JobsDatabase::addJob(Job &job)
job.updatedAt = now; job.updatedAt = now;
QSqlQuery query(db); QSqlQuery query(db);
query.prepare(uR"( query.prepare(
uR"(
INSERT INTO jobs (company, title, location, remote_type, source, url, date_applied, INSERT INTO jobs (company, title, location, remote_type, source, url, date_applied,
salary_min, salary_max, salary_expectation, currency, notes, contact, salary_min, salary_max, salary_expectation, currency, notes, contact,
stage, created_at, updated_at) stage, created_at, updated_at)
@@ -252,7 +382,8 @@ bool JobsDatabase::updateJob(const Job &job)
{ {
QSqlDatabase db = QSqlDatabase::database(m_connectionName); QSqlDatabase db = QSqlDatabase::database(m_connectionName);
QSqlQuery query(db); QSqlQuery query(db);
query.prepare(uR"( query.prepare(
uR"(
UPDATE jobs SET company = :company, title = :title, location = :location, UPDATE jobs SET company = :company, title = :title, location = :location,
remote_type = :remote_type, source = :source, url = :url, remote_type = :remote_type, source = :source, url = :url,
date_applied = :date_applied, salary_min = :salary_min, date_applied = :date_applied, salary_min = :salary_min,
@@ -294,13 +425,14 @@ bool JobsDatabase::setStage(int id, const QString &newStage)
m_lastError = u"Unknown stage '%1'"_s.arg(newStage); m_lastError = u"Unknown stage '%1'"_s.arg(newStage);
return false; return false;
} }
const QString stage = JobStage::canonical(newStage);
const auto current = jobById(id); const auto current = jobById(id);
if (!current) { if (!current) {
m_lastError = u"No job with id %1"_s.arg(id); m_lastError = u"No job with id %1"_s.arg(id);
return false; return false;
} }
if (current->stage.compare(newStage, Qt::CaseInsensitive) == 0) { if (current->stage.compare(stage, Qt::CaseInsensitive) == 0) {
return true; return true;
} }
@@ -309,7 +441,7 @@ bool JobsDatabase::setStage(int id, const QString &newStage)
QSqlQuery query(db); QSqlQuery query(db);
query.prepare(u"UPDATE jobs SET stage = :stage, updated_at = :updated_at WHERE id = :id"_s); query.prepare(u"UPDATE jobs SET stage = :stage, updated_at = :updated_at WHERE id = :id"_s);
query.bindValue(u":stage"_s, newStage); query.bindValue(u":stage"_s, stage);
query.bindValue(u":updated_at"_s, now.toString(Qt::ISODate)); query.bindValue(u":updated_at"_s, now.toString(Qt::ISODate));
query.bindValue(u":id"_s, id); query.bindValue(u":id"_s, id);
if (!query.exec()) { if (!query.exec()) {
@@ -321,7 +453,7 @@ bool JobsDatabase::setStage(int id, const QString &newStage)
history.prepare(u"INSERT INTO stage_history (job_id, from_stage, to_stage, changed_at) VALUES (:job_id, :from_stage, :to_stage, :changed_at)"_s); history.prepare(u"INSERT INTO stage_history (job_id, from_stage, to_stage, changed_at) VALUES (:job_id, :from_stage, :to_stage, :changed_at)"_s);
history.bindValue(u":job_id"_s, id); history.bindValue(u":job_id"_s, id);
history.bindValue(u":from_stage"_s, current->stage); history.bindValue(u":from_stage"_s, current->stage);
history.bindValue(u":to_stage"_s, newStage); history.bindValue(u":to_stage"_s, stage);
history.bindValue(u":changed_at"_s, now.toString(Qt::ISODate)); history.bindValue(u":changed_at"_s, now.toString(Qt::ISODate));
if (!history.exec()) { if (!history.exec()) {
m_lastError = history.lastError().text(); m_lastError = history.lastError().text();
@@ -347,9 +479,154 @@ bool JobsDatabase::deleteJob(int id)
return true; 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> JobsDatabase::stageTransitions() const
{ {
QList<StageTransition> transitions; QList<StageTransition> transitions;
if (!isOpen()) {
return transitions;
}
QSqlQuery query(QSqlDatabase::database(m_connectionName)); 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); 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()) { if (!query.exec()) {
+59 -3
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once #pragma once
#include "job.h" #include "job.h"
@@ -26,10 +31,42 @@ public:
JobsDatabase(const JobsDatabase &) = delete; JobsDatabase(const JobsDatabase &) = delete;
JobsDatabase &operator=(const JobsDatabase &) = delete; JobsDatabase &operator=(const JobsDatabase &) = delete;
/// Default location: $XDG_DATA_HOME/kareer/kareer.sqlite, overridable /// The database file a default-constructed instance opens. Resolved in
/// with the KAREER_DB_PATH environment variable (used by autotests). /// 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(); 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; bool isOpen() const;
QString lastError() const; QString lastError() const;
@@ -50,13 +87,32 @@ public:
bool deleteJob(int id); 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; QList<StageTransition> stageTransitions() const;
private: private:
void init(const QString &path); void init(const QString &path);
void close();
bool migrate(); bool migrate();
Job jobFromQuery(class QSqlQuery &query) const; Job jobFromQuery(class QSqlQuery &query) const;
QString m_connectionName; QString m_connectionName;
QString m_path;
QString m_lastError; QString m_lastError;
}; };
+26 -6
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "jobsmodel.h" #include "jobsmodel.h"
#include "jobstage.h" #include "jobstage.h"
@@ -12,6 +17,7 @@ JobsModel::JobsModel(QObject *parent)
void JobsModel::refresh() void JobsModel::refresh()
{ {
m_db.reopenIfPathChanged();
beginResetModel(); beginResetModel();
m_jobs = m_db.allJobs(); m_jobs = m_db.allJobs();
endResetModel(); endResetModel();
@@ -161,14 +167,14 @@ QVariantMap JobsModel::jobData(int id) const
return mapFromJob(*job); return mapFromJob(*job);
} }
bool JobsModel::addJob(const QVariantMap &fields) int JobsModel::addJob(const QVariantMap &fields)
{ {
Job job = jobFromMap(fields); Job job = jobFromMap(fields);
const bool ok = m_db.addJob(job); if (!m_db.addJob(job)) {
if (ok) { return -1;
refresh();
} }
return ok; refresh();
return job.id;
} }
bool JobsModel::updateJob(int id, const QVariantMap &fields) bool JobsModel::updateJob(int id, const QVariantMap &fields)
@@ -200,6 +206,20 @@ bool JobsModel::removeJob(int id)
return ok; 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 QString JobsModel::lastError() const
{ {
return m_db.lastError(); return m_db.lastError();
+11 -2
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once #pragma once
#include "job.h" #include "job.h"
@@ -55,11 +60,15 @@ public:
/// Full record for one job, for prefilling the edit dialog. /// Full record for one job, for prefilling the edit dialog.
Q_INVOKABLE QVariantMap jobData(int id) const; 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 updateJob(int id, const QVariantMap &fields);
Q_INVOKABLE bool setStage(int id, const QString &stage); Q_INVOKABLE bool setStage(int id, const QString &stage);
Q_INVOKABLE bool removeJob(int id); 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; Q_INVOKABLE QString lastError() const;
public Q_SLOTS: public Q_SLOTS:
+20 -3
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "jobstage.h" #include "jobstage.h"
#include <QHash> #include <QHash>
@@ -27,6 +32,19 @@ bool isValid(const QString &stage)
return canonicalStages().contains(stage, Qt::CaseInsensitive); return canonicalStages().contains(stage, Qt::CaseInsensitive);
} }
QString canonical(const QString &stage)
{
if (stage.compare(QLatin1String(Start), Qt::CaseInsensitive) == 0) {
return QString::fromLatin1(Start);
}
for (const QString &candidate : canonicalStages()) {
if (stage.compare(candidate, Qt::CaseInsensitive) == 0) {
return candidate;
}
}
return stage;
}
int column(const QString &stage) int column(const QString &stage)
{ {
static const QHash<QString, int> columns{ static const QHash<QString, int> columns{
@@ -86,8 +104,7 @@ QColor color(const QString &stage)
bool isTerminal(const QString &stage) bool isTerminal(const QString &stage)
{ {
return stage == QLatin1String("Accepted") || stage == QLatin1String("Rejected") || stage == QLatin1String("Withdrawn") return stage == QLatin1String("Accepted") || stage == QLatin1String("Rejected") || stage == QLatin1String("Withdrawn") || stage == QLatin1String("Ghosted");
|| stage == QLatin1String("Ghosted");
} }
} }
+11 -1
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once #pragma once
#include <QColor> #include <QColor>
@@ -20,6 +25,11 @@ QStringList canonicalStages();
bool isValid(const QString &stage); bool isValid(const QString &stage);
/// Canonical-case spelling for a stage matched case-insensitively
/// ("rejected" -> "Rejected", "start" -> Start). Unknown input is returned
/// unchanged.
QString canonical(const QString &stage);
/// Sankey column index. Start = 0; Applied..Offer walk the funnel; the three /// Sankey column index. Start = 0; Applied..Offer walk the funnel; the three
/// terminal outcomes (Accepted/Rejected/Withdrawn/Ghosted) share the last /// terminal outcomes (Accepted/Rejected/Withdrawn/Ghosted) share the last
/// column so a rejection right after Applied is still a valid (longer) link. /// column so a rejection right after Applied is still a valid (longer) link.
+99 -11
View File
@@ -1,7 +1,15 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "kareer-version.h" #include "kareer-version.h"
#include "autoghost.h"
#include "clicommands.h" #include "clicommands.h"
#include "databaselocation.h"
#include "jobsdatabase.h"
#include <KAboutData> #include <KAboutData>
#include <KCrash> #include <KCrash>
@@ -12,6 +20,7 @@
#include <QApplication> #include <QApplication>
#include <QCommandLineParser> #include <QCommandLineParser>
#include <QCoreApplication> #include <QCoreApplication>
#include <QFileInfo>
#include <QIcon> #include <QIcon>
#include <QQmlApplicationEngine> #include <QQmlApplicationEngine>
#include <QQuickStyle> #include <QQuickStyle>
@@ -20,12 +29,32 @@ using namespace Qt::Literals::StringLiterals;
// Filter out a couple of well-known benign framework artifacts rather than // Filter out a couple of well-known benign framework artifacts rather than
// spamming every run. Everything else is passed through untouched. // spamming every run. Everything else is passed through untouched.
static QtMessageHandler s_defaultMessageHandler = nullptr; static bool isBenignFrameworkNoise(const QString &message)
static void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &message)
{ {
// Qt Quick emits this while Kirigami's PageRow incubates pages. It fires even // Qt Quick emits this while Kirigami's PageRow incubates pages. It fires even
// for a trivial empty page, is harmless, and cannot be avoided from app code. // for a trivial empty page, is harmless, and cannot be avoided from app code.
if (message.contains(QLatin1String("was not placed in the graphics scene"))) { if (message.contains(QLatin1String("was not placed in the graphics scene"))) {
return true;
}
// An internal PageRow/StackView implementation detail, not something app
// code can influence.
if (message.contains(QLatin1String("StackView has detected conflicting anchors"))) {
return true;
}
// Qt's Wayland integration tries to self-register with xdg-desktop-portal for
// optional desktop features (global shortcuts, background). Kareer doesn't use
// any of those, and it fires harmlessly on hosts where portal app-info
// resolution is finicky.
if (message.contains(QLatin1String("Failed to register with host portal"))) {
return true;
}
return false;
}
static QtMessageHandler s_defaultMessageHandler = nullptr;
static void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &message)
{
if (isBenignFrameworkNoise(message)) {
return; return;
} }
// Malformed path data in third-party icon SVGs (system/app icon themes) is not // Malformed path data in third-party icon SVGs (system/app icon themes) is not
@@ -33,24 +62,59 @@ static void messageHandler(QtMsgType type, const QMessageLogContext &context, co
if (context.category && qstrcmp(context.category, "qt.svg") == 0) { if (context.category && qstrcmp(context.category, "qt.svg") == 0) {
return; return;
} }
// Qt's Wayland integration tries to self-register with xdg-desktop-portal for
// optional desktop features (global shortcuts, background). Kareer doesn't use
// any of those, and it fires harmlessly on hosts where portal app-info
// resolution is finicky.
if (message.contains(QLatin1String("Failed to register with host portal"))) {
return;
}
if (s_defaultMessageHandler) { if (s_defaultMessageHandler) {
s_defaultMessageHandler(type, context, message); s_defaultMessageHandler(type, context, message);
} }
} }
/// 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[]) int main(int argc, char *argv[])
{ {
s_defaultMessageHandler = qInstallMessageHandler(messageHandler); s_defaultMessageHandler = qInstallMessageHandler(messageHandler);
if (!takeDbOption(argc, argv)) {
return 1;
}
if (argc >= 2 && Cli::isSubcommand(QString::fromLocal8Bit(argv[1]))) { if (argc >= 2 && Cli::isSubcommand(QString::fromLocal8Bit(argv[1]))) {
QCoreApplication app(argc, argv); 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); return Cli::run(app);
} }
@@ -75,18 +139,42 @@ int main(int argc, char *argv[])
aboutData.setDesktopFileName(u"io.github.toservetheking.Kareer"_s); aboutData.setDesktopFileName(u"io.github.toservetheking.Kareer"_s);
KAboutData::setApplicationData(aboutData); 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(); KCrash::initialize();
QCommandLineParser parser; QCommandLineParser parser;
aboutData.setupCommandLine(&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); parser.process(app);
aboutData.processCommandLine(&parser); 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; QQmlApplicationEngine engine;
KLocalization::setupLocalizedContext(&engine); KLocalization::setupLocalizedContext(&engine);
QObject::connect(&engine, &QQmlApplicationEngine::warnings, &engine, [](const QList<QQmlError> &warnings) {
for (const QQmlError &error : warnings) {
if (isBenignFrameworkNoise(error.description())) {
continue;
}
fprintf(stderr, "QML-WARNING: %s\n", qPrintable(error.toString()));
}
fflush(stderr);
});
QObject::connect(&engine, &QQmlApplicationEngine::objectCreationFailed, &engine, [](const QUrl &url) {
fprintf(stderr, "QML-OBJECT-CREATION-FAILED: %s\n", qPrintable(url.toString()));
fflush(stderr);
});
engine.loadFromModule("io.github.toservetheking.Kareer", u"Main"_s); engine.loadFromModule("io.github.toservetheking.Kareer", u"Main"_s);
if (engine.rootObjects().isEmpty()) { if (engine.rootObjects().isEmpty()) {
return -1; return -1;
-216
View File
@@ -1,216 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.formcard as FormCard
import io.github.toservetheking.Kareer
FormCard.FormCardDialog {
id: root
required property JobsModel jobsModel
property int editingJobId: -1
title: editingJobId < 0 ? i18nc("@title:dialog", "Add Application") : i18nc("@title:dialog", "Edit Application")
standardButtons: QQC2.Dialog.Save | QQC2.Dialog.Cancel
function openForAdd(): void {
editingJobId = -1;
companyField.text = "";
titleField.text = "";
locationField.text = "";
remoteCombo.currentIndex = 0;
sourceField.text = "";
urlField.text = "";
dateField.value = new Date();
salaryMinField.value = 0;
salaryMaxField.value = 0;
salaryExpectationField.value = 0;
currencyField.text = "USD";
contactField.text = "";
notesField.text = "";
const stages = root.jobsModel.stages;
stageCombo.currentIndex = stages.indexOf("Applied");
errorLabel.text = "";
root.open();
}
function openForEdit(id: int): void {
editingJobId = id;
const data = root.jobsModel.jobData(id);
companyField.text = data.company;
titleField.text = data.title;
locationField.text = data.location;
remoteCombo.currentIndex = Math.max(0, remoteCombo.model.indexOf(data.remoteType));
sourceField.text = data.source;
urlField.text = data.url;
dateField.value = data.dateApplied;
salaryMinField.value = data.salaryMin > 0 ? data.salaryMin : 0;
salaryMaxField.value = data.salaryMax > 0 ? data.salaryMax : 0;
salaryExpectationField.value = data.salaryExpectation > 0 ? data.salaryExpectation : 0;
currencyField.text = data.currency;
contactField.text = data.contact;
notesField.text = data.notes;
const stages = root.jobsModel.stages;
stageCombo.currentIndex = Math.max(0, stages.indexOf(data.stage));
errorLabel.text = "";
root.open();
}
onAccepted: {
const fields = {
company: companyField.text,
title: titleField.text,
location: locationField.text,
remoteType: remoteCombo.currentIndex === 0 ? "" : remoteCombo.currentText,
source: sourceField.text,
url: urlField.text,
dateApplied: dateField.value,
salaryMin: salaryMinField.value > 0 ? salaryMinField.value : -1,
salaryMax: salaryMaxField.value > 0 ? salaryMaxField.value : -1,
salaryExpectation: salaryExpectationField.value > 0 ? salaryExpectationField.value : -1,
currency: currencyField.text,
contact: contactField.text,
notes: notesField.text,
stage: stageCombo.currentText,
};
const ok = root.editingJobId < 0 ? root.jobsModel.addJob(fields) : root.jobsModel.updateJob(root.editingJobId, fields);
if (!ok) {
errorLabel.text = root.jobsModel.lastError();
root.open();
}
}
FormCard.FormCard {
FormCard.FormTextFieldDelegate {
id: companyField
label: i18nc("@label:textbox", "Company")
}
FormCard.FormDelegateSeparator {}
FormCard.FormTextFieldDelegate {
id: titleField
label: i18nc("@label:textbox", "Job Title")
}
FormCard.FormDelegateSeparator {}
FormCard.FormTextFieldDelegate {
id: locationField
label: i18nc("@label:textbox", "Location")
}
FormCard.FormDelegateSeparator {}
FormCard.FormComboBoxDelegate {
id: remoteCombo
text: i18nc("@label:listbox", "Remote Type")
model: ["Unspecified", "Onsite", "Hybrid", "Remote"]
}
}
FormCard.FormCard {
FormCard.FormComboBoxDelegate {
id: stageCombo
text: i18nc("@label:listbox", "Stage")
model: root.jobsModel.stages
}
FormCard.FormDelegateSeparator {}
FormCard.FormHeader {
title: i18nc("@title:group", "Date Applied")
}
FormCard.FormDateTimeDelegate {
id: dateField
dateTimeDisplay: FormCard.FormDateTimeDelegate.DateTimeDisplay.Date
}
}
FormCard.FormCard {
FormCard.FormSpinBoxDelegate {
id: salaryMinField
label: i18nc("@label:spinbox", "Salary Range Minimum")
from: 0
to: 5000000
stepSize: 1000
}
FormCard.FormDelegateSeparator {}
FormCard.FormSpinBoxDelegate {
id: salaryMaxField
label: i18nc("@label:spinbox", "Salary Range Maximum")
from: 0
to: 5000000
stepSize: 1000
}
FormCard.FormDelegateSeparator {}
FormCard.FormSpinBoxDelegate {
id: salaryExpectationField
label: i18nc("@label:spinbox", "Your Salary Expectation")
from: 0
to: 5000000
stepSize: 1000
}
FormCard.FormDelegateSeparator {}
FormCard.FormTextFieldDelegate {
id: currencyField
label: i18nc("@label:textbox", "Currency")
}
}
FormCard.FormCard {
FormCard.FormTextFieldDelegate {
id: sourceField
label: i18nc("@label:textbox", "Source")
placeholderText: i18nc("@info:placeholder", "Referral, LinkedIn, company site...")
}
FormCard.FormDelegateSeparator {}
FormCard.FormTextFieldDelegate {
id: urlField
label: i18nc("@label:textbox", "Job Posting URL")
}
FormCard.FormDelegateSeparator {}
FormCard.FormTextFieldDelegate {
id: contactField
label: i18nc("@label:textbox", "Contact")
}
FormCard.FormDelegateSeparator {}
FormCard.FormTextAreaDelegate {
id: notesField
label: i18nc("@label:textbox", "Notes / Expectations")
}
}
FormCard.FormCard {
visible: root.editingJobId >= 0
FormCard.FormButtonDelegate {
text: i18nc("@action:button", "Delete Application")
icon.name: "edit-delete"
onClicked: deleteConfirmDialog.open()
}
}
Kirigami.InlineMessage {
id: errorLabel
Layout.fillWidth: true
Layout.margins: Kirigami.Units.smallSpacing
type: Kirigami.MessageType.Error
visible: text.length > 0
}
Kirigami.PromptDialog {
id: deleteConfirmDialog
title: i18nc("@title", "Delete Application")
subtitle: i18nc("@info", "Are you sure you want to delete this application? This cannot be undone.")
standardButtons: QQC2.Dialog.Cancel
customFooterActions: [
Kirigami.Action {
text: i18nc("@action:button", "Delete")
icon.name: "edit-delete"
onTriggered: {
root.jobsModel.removeJob(root.editingJobId);
deleteConfirmDialog.close();
root.close();
}
}
]
}
}
+396
View File
@@ -0,0 +1,396 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
SPDX-License-Identifier: GPL-3.0-or-later
*/
pragma ComponentBehavior: Bound
// Styled after KDE System Settings' Audio page (plasma-pa's kcm/ui/main.qml):
// Kirigami.ListSectionHeader per category, flat rows indented under the
// header and separated by Kirigami.Separator, no card/border around them.
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import Qt.labs.qmlmodels
import org.kde.kirigami as Kirigami
import org.kde.kitemmodels as KItemModels
import org.kde.kirigamiaddons.dateandtime as DateTime
import io.github.toservetheking.Kareer
Kirigami.ScrollablePage {
id: root
required property JobsModel jobsModel
property int editingJobId: -1
title: root.editingJobId < 0 ? i18nc("@title", "Add Application") : i18nc("@title", "Edit Application")
signal done()
leftPadding: 0
rightPadding: 0
// No topPadding: Kirigami.ListSectionHeader (the first thing on the
// page) already carries its own top padding, and stacking ours on top
// of that left an oversized gap above "Company", the first category.
bottomPadding: Kirigami.Units.gridUnit
JobEditModel {
id: editModel
jobsModel: root.jobsModel
editingJobId: root.editingJobId
}
actions: [
Kirigami.Action {
text: i18nc("@action:button", "Cancel")
icon.name: "dialog-cancel"
onTriggered: root.done()
},
Kirigami.Action {
text: i18nc("@action:button", "Delete")
icon.name: "edit-delete"
visible: root.editingJobId >= 0
onTriggered: deleteConfirmDialog.open()
},
Kirigami.Action {
text: i18nc("@action:button", "Save")
icon.name: "document-save"
// On failure, editModel.lastError is set and shown by errorLabel.
onTriggered: {
if (editModel.save()) {
root.done();
}
}
}
]
ColumnLayout {
spacing: 0
Kirigami.InlineMessage {
id: errorLabel
Layout.fillWidth: true
Layout.leftMargin: Kirigami.Units.largeSpacing
Layout.rightMargin: Kirigami.Units.largeSpacing
Layout.bottomMargin: Kirigami.Units.smallSpacing
type: Kirigami.MessageType.Error
text: editModel.lastError
visible: text.length > 0
}
Repeater {
model: editModel.categories
delegate: ColumnLayout {
id: section
required property var modelData
Layout.fillWidth: true
spacing: 0
Kirigami.ListSectionHeader {
Layout.fillWidth: true
text: section.modelData.title
}
KItemModels.KSortFilterProxyModel {
id: catModel
sourceModel: editModel
filterRoleName: "categoryId"
// No category id is a prefix of another, so this is an exact match.
filterString: section.modelData.id
}
// 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
Layout.topMargin: Kirigami.Units.smallSpacing
Layout.bottomMargin: Kirigami.Units.smallSpacing
spacing: Kirigami.Units.largeSpacing
Repeater {
id: rowRepeater
model: catModel
delegate: DelegateChooser {
role: "rowType"
// Text
DelegateChoice {
roleValue: 0
delegate: ColumnLayout {
id: textD
required property int index
required property string label
required property string value
required property string placeholder
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
QQC2.Label {
Layout.fillWidth: true
text: textD.label
}
QQC2.TextField {
Layout.fillWidth: true
text: textD.value
placeholderText: textD.placeholder
onTextEdited: editModel.setValue(textD.sourceRow, text)
}
Kirigami.Separator {
Layout.fillWidth: true
Layout.topMargin: Kirigami.Units.smallSpacing
visible: textD.index !== rowRepeater.count - 1
}
}
}
// Combo box
DelegateChoice {
roleValue: 1
delegate: ColumnLayout {
id: comboD
required property int index
required property string label
required property string value
required property var comboOptions
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
QQC2.Label {
Layout.fillWidth: true
text: comboD.label
}
QQC2.ComboBox {
Layout.fillWidth: true
model: comboD.comboOptions
currentIndex: comboD.comboOptions.indexOf(comboD.value)
onActivated: editModel.setValue(comboD.sourceRow, currentText)
}
Kirigami.Separator {
Layout.fillWidth: true
Layout.topMargin: Kirigami.Units.smallSpacing
visible: comboD.index !== rowRepeater.count - 1
}
}
}
// Spin box
DelegateChoice {
roleValue: 2
delegate: ColumnLayout {
id: spinD
required property int index
required property string label
required property var model
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
QQC2.Label {
Layout.fillWidth: true
text: spinD.label
}
QQC2.SpinBox {
id: spinBox
Layout.fillWidth: true
from: spinD.model.spinMin
to: spinD.model.spinMax
stepSize: 1000
// 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 {
Layout.fillWidth: true
Layout.topMargin: Kirigami.Units.smallSpacing
visible: spinD.index !== rowRepeater.count - 1
}
}
}
// Date
DelegateChoice {
roleValue: 3
delegate: ColumnLayout {
id: dateD
required property int index
required property string label
required property var value
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
QQC2.Label {
Layout.fillWidth: true
text: dateD.label
}
QQC2.Button {
Layout.fillWidth: true
text: Qt.formatDate(dateD.value, Qt.ISODate)
icon.name: "view-calendar-day"
onClicked: {
datePopup.value = dateD.value;
datePopup.open();
}
DateTime.DatePopup {
id: datePopup
onAccepted: editModel.setValue(dateD.sourceRow, value)
}
}
Kirigami.Separator {
Layout.fillWidth: true
Layout.topMargin: Kirigami.Units.smallSpacing
visible: dateD.index !== rowRepeater.count - 1
}
}
}
// Text area
DelegateChoice {
roleValue: 4
delegate: ColumnLayout {
id: areaD
required property int index
required property string label
required property string value
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
QQC2.Label {
Layout.fillWidth: true
text: areaD.label
}
QQC2.TextArea {
Layout.fillWidth: true
Layout.preferredHeight: Kirigami.Units.gridUnit * 5
wrapMode: TextEdit.Wrap
text: areaD.value
// 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 {
Layout.fillWidth: true
Layout.topMargin: Kirigami.Units.smallSpacing
visible: areaD.index !== rowRepeater.count - 1
}
}
}
}
}
}
}
}
}
Kirigami.PromptDialog {
id: deleteConfirmDialog
title: i18nc("@title", "Delete Application")
subtitle: i18nc("@info", "Are you sure you want to delete this application? This cannot be undone.")
standardButtons: QQC2.Dialog.Cancel
customFooterActions: [
Kirigami.Action {
text: i18nc("@action:button", "Delete")
icon.name: "edit-delete"
onTriggered: {
editModel.deleteJob();
deleteConfirmDialog.close();
root.done();
}
}
]
}
}
+17 -30
View File
@@ -1,6 +1,14 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
SPDX-License-Identifier: GPL-3.0-or-later
*/
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
// Sidebar/detail two-column layout: a search-filtered list of applications
// with a RoundedItemDelegate + SubtitleContentItem delegate, where the
// currently-open entry stays highlighted.
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import org.kde.kirigami as Kirigami import org.kde.kirigami as Kirigami
@@ -12,7 +20,9 @@ Kirigami.ScrollablePage {
id: root id: root
required property JobsModel jobsModel required property JobsModel jobsModel
required property var editDialog property int currentJobId: -1
signal editRequested(int jobId)
title: i18nc("@title", "Applications") title: i18nc("@title", "Applications")
@@ -21,14 +31,6 @@ Kirigami.ScrollablePage {
onTextChanged: filteredJobs.filterString = text onTextChanged: filteredJobs.filterString = text
} }
actions: [
Kirigami.Action {
text: i18nc("@action:button", "Add Application")
icon.name: "list-add"
onTriggered: root.editDialog.openForAdd()
}
]
KItemModels.KSortFilterProxyModel { KItemModels.KSortFilterProxyModel {
id: filteredJobs id: filteredJobs
sourceModel: root.jobsModel sourceModel: root.jobsModel
@@ -49,32 +51,17 @@ Kirigami.ScrollablePage {
required property string company required property string company
required property string title required property string title
required property string stage required property string stage
required property var dateApplied
required property int salaryMin
required property int salaryMax
required property string currency
text: jobDelegate.company text: jobDelegate.company
icon.source: "network-workgroup-symbolic"
highlighted: root.currentJobId === jobDelegate.jobId
contentItem: Delegates.SubtitleContentItem { contentItem: Delegates.SubtitleContentItem {
itemDelegate: jobDelegate itemDelegate: jobDelegate
subtitle: { subtitle: jobDelegate.title + " · " + jobDelegate.stage
const parts = [jobDelegate.title, jobDelegate.stage];
if (jobDelegate.dateApplied) {
parts.push(Qt.formatDate(jobDelegate.dateApplied, "yyyy-MM-dd"));
}
if (jobDelegate.salaryMin >= 0 || jobDelegate.salaryMax >= 0) {
let salary = jobDelegate.currency + " ";
salary += jobDelegate.salaryMin >= 0 ? jobDelegate.salaryMin : "?";
salary += "";
salary += jobDelegate.salaryMax >= 0 ? jobDelegate.salaryMax : "?";
parts.push(salary);
}
return parts.join(" · ");
}
} }
onClicked: root.editDialog.openForEdit(jobDelegate.jobId) onClicked: root.editRequested(jobDelegate.jobId)
} }
Kirigami.PlaceholderMessage { Kirigami.PlaceholderMessage {
@@ -83,7 +70,7 @@ Kirigami.ScrollablePage {
visible: jobList.count === 0 visible: jobList.count === 0
icon.name: "office-address-book-symbolic" icon.name: "office-address-book-symbolic"
text: i18n("No applications yet") text: i18n("No applications yet")
explanation: i18n("Use the Add Application button to log your first one.") explanation: i18n("Use the Add Application button on the dashboard to log your first one.")
} }
} }
} }
+17 -2
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
SPDX-License-Identifier: GPL-3.0-or-later
*/
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import QtQuick import QtQuick
@@ -12,8 +17,18 @@ Kirigami.ScrollablePage {
required property JobsModel jobsModel required property JobsModel jobsModel
signal addRequested()
title: i18nc("@title", "Dashboard") title: i18nc("@title", "Dashboard")
actions: [
Kirigami.Action {
text: i18nc("@action:button", "Add Application")
icon.name: "list-add"
onTriggered: root.addRequested()
}
]
StatsModel { StatsModel {
id: statsModel id: statsModel
} }
@@ -57,7 +72,7 @@ Kirigami.ScrollablePage {
} }
QQC2.Label { QQC2.Label {
text: statCard.modelData.label text: statCard.modelData.label
opacity: 0.7 color: Kirigami.Theme.disabledTextColor
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
Layout.fillWidth: true Layout.fillWidth: true
} }
+104
View File
@@ -0,0 +1,104 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
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();
}
}
}
}
+113 -7
View File
@@ -1,8 +1,14 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
SPDX-License-Identifier: GPL-3.0-or-later
*/
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import org.kde.kirigami as Kirigami import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.formcard as FormCard
import io.github.toservetheking.Kareer import io.github.toservetheking.Kareer
Kirigami.ApplicationWindow { Kirigami.ApplicationWindow {
@@ -19,22 +25,113 @@ Kirigami.ApplicationWindow {
id: jobsModel id: jobsModel
} }
ApplicationEditDialog { // The database moved (first-run choice or Preferences): reload the list,
id: editDialog // which cascades to the dashboard via countChanged, and leave any open
jobsModel: jobsModel // edit form, since its job id belonged to the previous database.
Connections {
target: DatabaseLocation
function onChanged(): void {
AutoGhost.run();
jobsModel.refresh();
root.showDashboard();
}
} }
pageStack.defaultColumnWidth: Kirigami.Units.gridUnit * 22 // 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
pageStack.defaultColumnWidth: Kirigami.Units.gridUnit * 16
pageStack.globalToolBar.style: Kirigami.ApplicationHeaderStyle.ToolBar pageStack.globalToolBar.style: Kirigami.ApplicationHeaderStyle.ToolBar
// Two static columns (applications + dashboard) - no dynamic page pushing. globalDrawer: Kirigami.GlobalDrawer {
isMenu: true
actions: [
Kirigami.Action {
text: i18nc("@action:inmenu", "Preferences…")
icon.name: "configure"
onTriggered: root.pageStack.pushDialogLayer(settingsPageComponent, {
width: root.width
}, {
width: Kirigami.Units.gridUnit * 24,
height: Kirigami.Units.gridUnit * 20,
modality: Qt.NonModal
})
},
Kirigami.Action {
text: i18nc("@action:inmenu", "About %1", root.title)
icon.name: "help-about"
onTriggered: root.pageStack.pushDialogLayer(aboutPageComponent, {
width: root.width
}, {
width: Kirigami.Units.gridUnit * 30,
height: Kirigami.Units.gridUnit * 30,
modality: Qt.NonModal
})
}
]
}
Component {
id: aboutPageComponent
FormCard.AboutPage {}
}
Component {
id: settingsPageComponent
SettingsPage {}
}
// Two static columns: the applications list (sidebar) and the dashboard.
// "Add Application" (and clicking a row) replaces just the dashboard
// column with the edit form - the sidebar and its list are untouched.
pageStack.initialPage: [applicationsComponent, dashboardComponent] pageStack.initialPage: [applicationsComponent, dashboardComponent]
function showDashboard(): void {
root.currentJobId = -1;
root.pageStack.currentIndex = 1;
root.pageStack.replace(dashboardComponent);
}
function showEditPage(jobId: int): void {
root.currentJobId = jobId;
root.pageStack.currentIndex = 1;
root.pageStack.replace(editPageComponent, {editingJobId: jobId});
}
Component { Component {
id: applicationsComponent id: applicationsComponent
ApplicationsPage { ApplicationsPage {
jobsModel: jobsModel jobsModel: jobsModel
editDialog: editDialog currentJobId: root.currentJobId
onEditRequested: jobId => root.showEditPage(jobId)
} }
} }
@@ -42,6 +139,15 @@ Kirigami.ApplicationWindow {
id: dashboardComponent id: dashboardComponent
DashboardPage { DashboardPage {
jobsModel: jobsModel jobsModel: jobsModel
onAddRequested: root.showEditPage(-1)
}
}
Component {
id: editPageComponent
ApplicationEditPage {
jobsModel: jobsModel
onDone: root.showDashboard()
} }
} }
} }
+35 -10
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
SPDX-License-Identifier: GPL-3.0-or-later
*/
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import QtQuick import QtQuick
@@ -13,21 +18,29 @@ Item {
id: root id: root
readonly property bool empty: sankeyModel.empty readonly property bool empty: sankeyModel.empty
readonly property real nodeWidth: Math.round(Kirigami.Units.gridUnit * 0.75)
readonly property real rowPadding: Kirigami.Units.gridUnit
SankeyModel { SankeyModel {
id: sankeyModel id: sankeyModel
} }
function refresh(): void { function refresh(): void {
if (root.width > 0 && root.height > 0) { sankeyModel.reload(root.width, root.height, root.nodeWidth, root.rowPadding);
sankeyModel.relayout(root.width, root.height);
}
} }
onWidthChanged: refresh() // Resizes only need fresh geometry, not a database re-read, and are
onHeightChanged: refresh() // debounced so a window drag doesn't rebuild every delegate per pixel.
onWidthChanged: relayoutTimer.restart()
onHeightChanged: relayoutTimer.restart()
Component.onCompleted: refresh() Component.onCompleted: refresh()
Timer {
id: relayoutTimer
interval: 150
onTriggered: sankeyModel.relayout(root.width, root.height, root.nodeWidth, root.rowPadding)
}
Kirigami.PlaceholderMessage { Kirigami.PlaceholderMessage {
anchors.centerIn: parent anchors.centerIn: parent
width: parent.width - Kirigami.Units.gridUnit * 4 width: parent.width - Kirigami.Units.gridUnit * 4
@@ -41,7 +54,6 @@ Item {
model: sankeyModel.links model: sankeyModel.links
delegate: Shape { delegate: Shape {
required property var modelData required property var modelData
asynchronous: true
ShapePath { ShapePath {
fillColor: modelData.color fillColor: modelData.color
strokeColor: "transparent" strokeColor: "transparent"
@@ -74,14 +86,27 @@ Item {
hoverEnabled: true hoverEnabled: true
} }
// Subtle backing so the label stays legible where ribbons pass
// underneath it.
Rectangle {
anchors.fill: labelHeading
anchors.margins: -Kirigami.Units.smallSpacing / 2
radius: 3
color: Kirigami.Theme.backgroundColor
opacity: 0.6
}
Kirigami.Heading { Kirigami.Heading {
id: labelHeading
level: 5 level: 5
anchors.left: parent.right anchors.left: nodeDelegate.modelData.labelOnRight ? parent.right : undefined
anchors.right: nodeDelegate.modelData.labelOnRight ? undefined : parent.left
anchors.leftMargin: Kirigami.Units.smallSpacing anchors.leftMargin: Kirigami.Units.smallSpacing
anchors.rightMargin: Kirigami.Units.smallSpacing
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
width: nodeDelegate.modelData.labelWidth width: Math.min(nodeDelegate.modelData.labelWidth, implicitWidth)
horizontalAlignment: nodeDelegate.modelData.labelOnRight ? Text.AlignLeft : Text.AlignRight
text: nodeDelegate.modelData.label text: nodeDelegate.modelData.label
visible: nodeDelegate.height >= Kirigami.Units.gridUnit
elide: Text.ElideRight elide: Text.ElideRight
} }
} }
+220
View File
@@ -0,0 +1,220 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
SPDX-License-Identifier: GPL-3.0-or-later
*/
pragma ComponentBehavior: Bound
// Styled after KDE System Settings' Audio page (plasma-pa's kcm/ui/main.qml):
// Kirigami.ListSectionHeader per section, flat rows indented under the
// header, no card/border around them.
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Dialogs
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import io.github.toservetheking.Kareer
Kirigami.ScrollablePage {
id: root
title: i18nc("@title:window", "Preferences")
leftPadding: 0
rightPadding: 0
// No topPadding: Kirigami.ListSectionHeader (the first thing on the
// page) already carries its own top padding, and stacking ours on top
// of that left an oversized gap above "Appearance".
bottomPadding: Kirigami.Units.gridUnit
ColumnLayout {
width: root.width
spacing: 0
Kirigami.ListSectionHeader {
Layout.fillWidth: true
text: i18nc("@title:group", "Appearance")
}
ColumnLayout {
Layout.fillWidth: true
Layout.leftMargin: Kirigami.Units.largeSpacing
Layout.rightMargin: Kirigami.Units.largeSpacing
Layout.topMargin: Kirigami.Units.smallSpacing
Layout.bottomMargin: Kirigami.Units.smallSpacing
spacing: Kirigami.Units.smallSpacing
QQC2.Label {
Layout.fillWidth: true
text: i18nc("@label:listbox", "Color scheme")
}
QQC2.ComboBox {
id: colorSchemeCombo
Layout.fillWidth: true
model: AppColorScheme.colorSchemesModel
textRole: "display"
delegate: QQC2.ItemDelegate {
id: schemeDelegate
required property var model
width: colorSchemeCombo.width
icon.source: "image://colorScheme/" + schemeDelegate.model.display
icon.color: "transparent"
text: schemeDelegate.model.display
highlighted: schemeDelegate.model.display === AppColorScheme.activeColorSchemeName
onClicked: {
AppColorScheme.activeColorSchemeName = schemeDelegate.model.display;
colorSchemeCombo.popup.close();
}
}
// Keep the closed-box label in sync without fighting the popup's own selection state.
displayText: AppColorScheme.activeColorSchemeName
}
}
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)
}
}
+586 -135
View File
@@ -1,23 +1,33 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "sankeymodel.h" #include "sankeymodel.h"
#include "jobstage.h" #include "jobstage.h"
#include <KLocalizedString> #include <KLocalizedString>
#include <QColor>
#include <QHash> #include <QHash>
#include <QPair> #include <QPair>
#include <QPointF>
#include <QSet> #include <QSet>
#include <QVariantMap> #include <QVariantMap>
#include <algorithm> #include <algorithm>
#include <iterator> #include <cmath>
#include <limits>
using namespace Qt::Literals::StringLiterals; using namespace Qt::Literals::StringLiterals;
namespace 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) QString point(qreal x, qreal y)
@@ -25,17 +35,60 @@ QString point(qreal x, qreal y)
return num(x) + u","_s + num(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 { struct NodeInfo {
QString stage; QString stage;
int column = 0; int column = 0;
int rank = 0; ///< Index among the columns actually present.
int order = 0; int order = 0;
int value = 0; int value = 0;
bool sink = false;
qreal x = 0; qreal x = 0;
qreal y = 0; qreal y = 0;
qreal width = 0; qreal width = 0;
qreal height = 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) SankeyModel::SankeyModel(QObject *parent)
: QObject(parent) : QObject(parent)
@@ -57,128 +110,140 @@ bool SankeyModel::isEmpty() const
return m_nodes.isEmpty(); return m_nodes.isEmpty();
} }
void SankeyModel::relayout(qreal width, qreal height) void SankeyModel::reload(qreal width, qreal height, qreal nodeWidth, qreal padding)
{
m_db.reopenIfPathChanged();
m_counts.clear();
// 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);
}
void SankeyModel::relayout(qreal width, qreal height, qreal nodeWidth, qreal padding)
{ {
m_nodes.clear(); m_nodes.clear();
m_links.clear(); m_links.clear();
if (width <= 0 || height <= 0) { if (width <= 0 || height <= 0 || nodeWidth <= 0 || m_counts.isEmpty()) {
Q_EMIT changed(); Q_EMIT changed();
return; return;
} }
const QList<StageTransition> transitions = m_db.stageTransitions(); padding = qMax<qreal>(0.0, padding);
QHash<QPair<QString, QString>, int> counts;
for (const StageTransition &t : transitions) {
const QString from = t.fromStage.isEmpty() ? QString::fromLatin1(JobStage::Start) : t.fromStage;
counts[{from, t.toStage}] += 1;
}
if (counts.isEmpty()) {
Q_EMIT changed();
return;
}
//
// 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> inbound;
QHash<QString, int> outbound; QHash<QString, int> outbound;
QSet<QString> stageNames; QSet<QString> stageNames;
for (auto it = counts.constBegin(); it != counts.constEnd(); ++it) { QList<QPair<QString, QString>> linkKeys;
for (auto it = m_counts.constBegin(); it != m_counts.constEnd(); ++it) {
const QString &from = it.key().first; const QString &from = it.key().first;
const QString &to = it.key().second; const QString &to = it.key().second;
if (it.value() <= 0 || from.isEmpty() || to.isEmpty() || JobStage::column(to) <= JobStage::column(from)) {
continue;
}
outbound[from] += it.value(); outbound[from] += it.value();
inbound[to] += it.value(); inbound[to] += it.value();
stageNames.insert(from); stageNames.insert(from);
stageNames.insert(to); stageNames.insert(to);
linkKeys.append(it.key());
} }
if (stageNames.isEmpty()) {
Q_EMIT changed();
return;
}
//
// Nodes and the columns actually present.
//
QList<NodeInfo> nodeList; QList<NodeInfo> nodeList;
nodeList.reserve(stageNames.size()); nodeList.reserve(stageNames.size());
for (const QString &stage : std::as_const(stageNames)) { for (const QString &stage : std::as_const(stageNames)) {
NodeInfo n; NodeInfo node;
n.stage = stage; node.stage = stage;
n.column = JobStage::column(stage); node.column = JobStage::column(stage);
n.order = JobStage::orderInColumn(stage); node.order = JobStage::orderInColumn(stage);
n.value = qMax(inbound.value(stage, 0), outbound.value(stage, 0)); node.sink = isSink(stage);
nodeList.append(n); // 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) { std::sort(nodeList.begin(), nodeList.end(), [](const NodeInfo &a, const NodeInfo &b) {
if (a.column != b.column) { if (a.column != b.column) {
return a.column < b.column; return a.column < b.column;
} }
return a.order < b.order; if (a.order != b.order) {
return a.order < b.order;
}
return a.stage < b.stage;
}); });
QHash<int, QList<int>> columnIndices; QList<int> usedColumns;
int maxColumn = 0; for (const NodeInfo &node : std::as_const(nodeList)) {
for (int i = 0; i < nodeList.size(); ++i) { if (!usedColumns.contains(node.column)) {
columnIndices[nodeList.at(i).column].append(i); usedColumns.append(node.column);
maxColumn = qMax(maxColumn, nodeList.at(i).column);
}
constexpr qreal nodeWidth = 16.0;
constexpr qreal padding = 10.0;
// A single vertical scale is shared by every column: the column with the
// largest total flow determines it, so no column can overflow the
// available height.
qreal scale = 1.0;
bool haveScale = false;
for (auto it = columnIndices.constBegin(); it != columnIndices.constEnd(); ++it) {
int total = 0;
for (int idx : it.value()) {
total += nodeList.at(idx).value;
}
if (total <= 0) {
continue;
}
const qreal gaps = padding * qMax(0, it.value().size() - 1);
const qreal available = qMax<qreal>(1.0, height - gaps);
const qreal candidate = available / total;
if (!haveScale || candidate < scale) {
scale = candidate;
haveScale = true;
}
}
if (!haveScale) {
scale = 1.0;
}
for (auto it = columnIndices.begin(); it != columnIndices.end(); ++it) {
const QList<int> &idxs = it.value();
qreal totalHeight = 0;
for (int idx : idxs) {
totalHeight += qMax<qreal>(nodeList.at(idx).value * scale, 2.0);
}
const qreal gaps = padding * qMax(0, idxs.size() - 1);
const qreal startY = qMax<qreal>(0.0, (height - totalHeight - gaps) / 2.0);
const qreal x = maxColumn > 0 ? (it.key() * (width - nodeWidth) / maxColumn) : 0.0;
qreal cursorY = startY;
for (int idx : idxs) {
NodeInfo &n = nodeList[idx];
n.x = x;
n.y = cursorY;
n.width = nodeWidth;
n.height = qMax<qreal>(n.value * scale, 2.0);
cursorY += n.height + padding;
} }
} }
std::sort(usedColumns.begin(), usedColumns.end());
const int rankCount = usedColumns.size();
QHash<QString, int> nodeIndexByStage; QHash<QString, int> nodeIndexByStage;
for (int i = 0; i < nodeList.size(); ++i) { for (int i = 0; i < nodeList.size(); ++i) {
nodeList[i].rank = usedColumns.indexOf(nodeList.at(i).column);
nodeIndexByStage.insert(nodeList.at(i).stage, i); nodeIndexByStage.insert(nodeList.at(i).stage, i);
} }
QHash<QString, qreal> sourceCursor; //
QHash<QString, qreal> targetCursor; // Links.
for (const NodeInfo &n : std::as_const(nodeList)) { //
sourceCursor.insert(n.stage, n.y);
targetCursor.insert(n.stage, n.y);
}
QList<QPair<QString, QString>> linkKeys = counts.keys();
std::sort(linkKeys.begin(), linkKeys.end(), [&](const QPair<QString, QString> &a, const QPair<QString, QString> &b) { std::sort(linkKeys.begin(), linkKeys.end(), [&](const QPair<QString, QString> &a, const QPair<QString, QString> &b) {
const int aFrom = nodeIndexByStage.value(a.first); const int aFrom = nodeIndexByStage.value(a.first);
const int bFrom = nodeIndexByStage.value(b.first); const int bFrom = nodeIndexByStage.value(b.first);
@@ -188,72 +253,458 @@ void SankeyModel::relayout(qreal width, qreal height)
return nodeIndexByStage.value(a.second) < nodeIndexByStage.value(b.second); return nodeIndexByStage.value(a.second) < nodeIndexByStage.value(b.second);
}); });
QList<LinkInfo> links;
links.reserve(linkKeys.size());
for (const auto &key : std::as_const(linkKeys)) { for (const auto &key : std::as_const(linkKeys)) {
const QString &from = key.first; LinkInfo link;
const QString &to = key.second; link.from = key.first;
const int value = counts.value(key); link.to = key.second;
const qreal thickness = qMax<qreal>(value * scale, 1.5); 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);
}
const NodeInfo &fromNode = nodeList.at(nodeIndexByStage.value(from)); //
const NodeInfo &toNode = nodeList.at(nodeIndexByStage.value(to)); // 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);
}
const qreal y0Top = sourceCursor.value(from); //
const qreal y0Bottom = y0Top + thickness; // What each column holds, top to bottom:
sourceCursor[from] = y0Bottom; //
// 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);
const qreal y1Top = targetCursor.value(to); for (int i = 0; i < links.size(); ++i) {
const qreal y1Bottom = y1Top + thickness; const LinkInfo &link = links.at(i);
targetCursor[to] = y1Bottom; 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);
}
const qreal x0 = fromNode.x + fromNode.width; //
const qreal x1 = toNode.x; // Shared scale: the largest for which every column's stack fits.
const qreal midX = (x0 + x1) / 2.0; //
// 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 QString path = u"M"_s + point(x0, y0Top) + u" C"_s + point(midX, y0Top) + u" "_s + point(midX, y1Top) + u" "_s + point(x1, y1Top) const auto laneThickness = [&](const QList<int> &lanes, qreal scale) {
+ 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; 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;
};
QColor linkColor = JobStage::color(from); qreal scale = 0.0;
linkColor.setAlphaF(0.5f); 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));
}
}
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;
});
qreal cursor = nodeList.at(n).y;
for (int index : std::as_const(slotOrder)) {
links[index].sourceY = cursor;
cursor += links.at(index).thickness;
}
};
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;
}
//
// 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;
}
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);
}
}
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;
});
qreal cursor = nodeList.at(n).y;
for (int index : std::as_const(slotOrder)) {
links[index].targetY = cursor;
cursor += links.at(index).thickness;
}
}
//
// 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);
// 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});
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;
QColor linkColor = JobStage::color(link.from);
linkColor.setAlphaF(0.5);
m_links.append(QVariantMap{ m_links.append(QVariantMap{
{u"fromStage"_s, from}, {u"fromStage"_s, link.from},
{u"toStage"_s, to}, {u"toStage"_s, link.to},
{u"value"_s, value}, {u"value"_s, link.value},
{u"pathData"_s, path}, {u"pathData"_s, path},
{u"color"_s, linkColor}, {u"color"_s, linkColor},
{u"thickness"_s, link.thickness},
{u"sourceY"_s, link.sourceY},
{u"targetY"_s, link.targetY},
}); });
} }
// Labels are drawn to the right of each node. Without a bound, a long //
// label (e.g. "Applications") can run into the next column's node and // Nodes and their labels. Labels go right of every column but the last,
// its own label. Cap each node's label to the gap before the next // whose labels go left.
// distinct column actually in use (skipping empty columns), so text //
// elides instead of overlapping. constexpr qreal labelMargin = 8.0;
QList<qreal> columnStarts; constexpr qreal minLabelWidth = 24.0;
for (const NodeInfo &n : std::as_const(nodeList)) { const int lastRank = rankCount - 1;
if (!columnStarts.contains(n.x)) {
columnStarts.append(n.x); for (const NodeInfo &node : std::as_const(nodeList)) {
const QString label = node.stage == QLatin1String(JobStage::Start) ? i18n("Applications") : i18n(node.stage.toUtf8().constData());
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 = node.x - (columnX.at(node.rank - 1) + node.width);
labelWidth = gap * 0.6 - 2.0 * labelMargin;
} }
} labelWidth = qMax(labelWidth, minLabelWidth);
std::sort(columnStarts.begin(), columnStarts.end());
for (const NodeInfo &n : std::as_const(nodeList)) {
const QString label = n.stage == QLatin1String(JobStage::Start) ? i18n("Applications") : i18n(n.stage.toUtf8().constData());
const int columnPos = static_cast<int>(std::distance(columnStarts.begin(), std::find(columnStarts.begin(), columnStarts.end(), n.x)));
const qreal nextColumnX = columnPos + 1 < columnStarts.size() ? columnStarts.at(columnPos + 1) : width;
const qreal labelWidth = qMax<qreal>(24.0, nextColumnX - (n.x + n.width) - 8.0);
m_nodes.append(QVariantMap{ m_nodes.append(QVariantMap{
{u"stage"_s, n.stage}, {u"stage"_s, node.stage},
{u"label"_s, label}, {u"label"_s, label},
{u"x"_s, n.x}, {u"x"_s, node.x},
{u"y"_s, n.y}, {u"y"_s, node.y},
{u"width"_s, n.width}, {u"width"_s, node.width},
{u"height"_s, n.height}, {u"height"_s, node.height},
{u"value"_s, n.value}, {u"value"_s, node.value},
{u"color"_s, JobStage::color(n.stage)}, {u"color"_s, JobStage::color(node.stage)},
{u"labelWidth"_s, labelWidth}, {u"labelWidth"_s, labelWidth},
{u"labelOnRight"_s, labelOnRight},
}); });
} }
+16 -4
View File
@@ -1,9 +1,16 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once #pragma once
#include "jobsdatabase.h" #include "jobsdatabase.h"
#include <QHash>
#include <QObject> #include <QObject>
#include <QPair>
#include <QQmlEngine> #include <QQmlEngine>
#include <QVariantList> #include <QVariantList>
@@ -34,15 +41,20 @@ public:
bool isEmpty() const; bool isEmpty() const;
public Q_SLOTS: public Q_SLOTS:
/// Recomputes node/link geometry to fit within (width, height) logical /// Re-reads the stage history from the database, then lays it out to fit
/// pixels. Call whenever the data or the available viewport changes. /// within (width, height) logical pixels. Call whenever the data changes.
void relayout(qreal width, qreal height); void reload(qreal width, qreal height, qreal nodeWidth = 16.0, qreal padding = 10.0);
/// Recomputes node/link geometry from the cached stage history. Call on
/// viewport resizes; unlike reload() this never touches the database.
void relayout(qreal width, qreal height, qreal nodeWidth = 16.0, qreal padding = 10.0);
Q_SIGNALS: Q_SIGNALS:
void changed(); void changed();
private: private:
JobsDatabase m_db; JobsDatabase m_db;
QHash<QPair<QString, QString>, int> m_counts;
QVariantList m_nodes; QVariantList m_nodes;
QVariantList m_links; QVariantList m_links;
}; };
+156
View File
@@ -0,0 +1,156 @@
/*
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
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 <austin@thebennett.net>
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;
};
+7 -1
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#include "statsmodel.h" #include "statsmodel.h"
#include "jobstage.h" #include "jobstage.h"
@@ -12,6 +17,7 @@ StatsModel::StatsModel(QObject *parent)
void StatsModel::refresh() void StatsModel::refresh()
{ {
m_db.reopenIfPathChanged();
m_jobs = m_db.allJobs(); m_jobs = m_db.allJobs();
Q_EMIT changed(); Q_EMIT changed();
} }
+6 -1
View File
@@ -1,4 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later /*
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
SPDX-License-Identifier: GPL-3.0-or-later
*/
#pragma once #pragma once
#include "jobsdatabase.h" #include "jobsdatabase.h"