desktop: an agent and a mini timer, not an application window

The design of the full desktop app was still bad, and the reason it stayed bad
is that it had no business existing. Five pages — Timer, Tasks, Blocker, Report,
Settings — each a second view of the *server's* data: a second place to be
wrong, a second place to style, and a second place to keep in step with a web
app that already does lists, rules and reports properly in a browser, on any
device. So it is gone, along with 2,100 lines of QML and the controller surface
that fed it.

What is left is what only a program on your machine can do, plus the two
surfaces worth having in front of you:

* **the tray menu — unchanged**, as asked. All fourteen entries, plus
  "Settings…". "Open Kadence" now brings the countdown up, because that is what
  it meant all along.
* **the mini timer**, which is now the whole of this app's own interface and so
  finally carries the house style on its own: warm surface, hairline instead of
  shadow, a letterspaced eyebrow, the progress lane as a rule, and exactly one
  filled button — whichever action is the obvious next one. The countdown is set
  in the display serif on a **fixed advance**, because Fraunces has no tabular
  figures and ships no `tnum`: each glyph now sits in a box as wide as the widest
  digit, instead of the clock shifting sideways on every tick. The phone client
  documents the same trap; the desktop had it too and nobody had looked.
  It also says OFFLINE when the backend is away — the count and the clock are
  both the server's, and this is the only window left to say it in.
* **a settings dialog**, because a fresh install has to be pointed at a backend
  somewhere. One column, hairline sections: the backend and its token, where the
  mini timer sits, what this machine enforces, and what it does to the session.
  It edits `desktop.json` and nothing else — with one honest exception, view-only
  mode, which is a server-side flag the other clients have to honour too.

Structurally, `app.py` no longer loads a QML root at all: a plain `QQmlEngine`
with the mini timer as a component, and the dialog built on demand. Everything
that used to ask "is the main window visible?" is simpler for not having one —
the mini timer's auto-show rule is now just "an interval is running", which is
what the setting always claimed.

The window-only controller surface goes with the pages: tasks and task writes,
stats, the daily report, the settings document, the presets, the cycle and
interruption counters, and their worker jobs and API calls. Two things were kept
deliberately: the task list is still read once a minute for one fact — which task
is pinned, so "Start focus" from the tray attaches what you pinned this morning —
and `timerEnabled` now comes from the timer state, which already carries
`timer_enabled`, rather than from a second fetch of a document this app can no
longer edit.

Also: `--settings` and a `Settings` D-Bus verb; toasts go to the tray's balloon
now that the snackbar is gone; the launcher entry starts the agent and grew a
Settings action; a systemd user unit is shipped rather than only written at
runtime; and both READMEs say what this piece is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
timka123andClaude Opus 5 committed 2026-10-22 18:00:52 +03:00
1 parent 81032aca51
commit 8e659cd315
26 files changed
+752 -2690

No files matched your search

@@ -1,82 +0,0 @@
// Pomodoros per day: magnitude over time, so bars, one hue, baseline at zero.
// The goal line is the only reference mark; days that cleared it take a darker
// step of the same ramp, which keeps "did I hit it" readable without a second
// colour.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ".."
Item {
id: chart
property var days: []
property int goal: 8
property int barHeight: 108
Layout.fillWidth: true
implicitHeight: barHeight + 16
readonly property int maxValue: {
var top = goal
for (var i = 0; i < days.length; i++) top = Math.max(top, days[i].pomodoros)
return Math.max(top, 1)
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
y: chart.barHeight - (chart.goal / chart.maxValue) * chart.barHeight
height: 1
color: Theme.rule
visible: chart.goal > 0
}
RowLayout {
anchors.left: parent.left
anchors.right: parent.right
height: chart.barHeight
spacing: 2
Repeater {
model: chart.days
delegate: Item {
required property var modelData
Layout.fillWidth: true
Layout.fillHeight: true
Rectangle {
anchors.bottom: parent.bottom
width: parent.width
height: Math.max(modelData.pomodoros > 0
? (modelData.pomodoros / chart.maxValue) * chart.barHeight : 2, 2)
radius: 3
color: modelData.pomodoros === 0
? Theme.rule
: (modelData.goal_met ? Theme.sequential[4] : Theme.sequential[2])
ToolTip.visible: hover.hovered
ToolTip.text: modelData.date + " · " + modelData.pomodoros
+ " pomodoros · " + modelData.focus_minutes + " min"
HoverHandler { id: hover }
Behavior on height { NumberAnimation { duration: 220 } }
}
}
}
}
RowLayout {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
Label {
text: chart.days.length ? chart.days[0].date.substring(5) : ""
font.pixelSize: 10; color: Theme.faint; font.family: Theme.sansFamily
}
Item { Layout.fillWidth: true }
Label {
text: chart.days.length ? chart.days[chart.days.length - 1].date.substring(5) : ""
font.pixelSize: 10; color: Theme.faint; font.family: Theme.sansFamily
}
}
}
@@ -0,0 +1,58 @@
// The countdown, in the display serif, on a fixed advance.
//
// Fraunces has **no tabular figures**: its digits are proportional (`1` is 1024
// units against `0` at 1461) and the font ships no `tnum` feature, so a clock
// set in it jitters sideways on every tick — the one thing a clock must not do.
// `font.features` cannot fix what the font does not contain, so each glyph is
// laid in a box as wide as the widest digit instead. The separators keep their
// natural width, because a colon in a digit-wide box reads as a gap.
//
// The Android client's `TabularFigures` solves the same problem the same way;
// Inter *does* ship `tnum`, which is why the sans numbers elsewhere only need a
// font feature.
import QtQuick
import ".."
Row {
id: figures
property string text: ""
property int pixelSize: 34
property color ink: Theme.ink
readonly property font figureFont: Qt.font({
family: Theme.displayFamily,
pixelSize: figures.pixelSize
})
// Measured rather than assumed: with Fraunces absent the fallback serif may
// already be tabular, in which case this is simply the digit width.
readonly property real cell: {
var widest = 0
for (var digit = 0; digit < 10; digit++)
widest = Math.max(widest, metrics.advanceWidth(String(digit)))
return widest
}
FontMetrics { id: metrics; font: figures.figureFont }
Repeater {
model: figures.text.length
delegate: Item {
required property int index
readonly property string glyph: figures.text.charAt(index)
readonly property bool isDigit: glyph >= "0" && glyph <= "9"
width: isDigit ? figures.cell : metrics.advanceWidth(glyph)
height: metrics.height
Text {
anchors.centerIn: parent
text: parent.glyph
font: figures.figureFont
color: figures.ink
}
}
}
}
@@ -1,55 +0,0 @@
// Eyebrow, title, actions, rule — the same opening every page uses.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ".."
ColumnLayout {
property string eyebrow: ""
property string title: ""
property string caption: ""
default property alias actions: actionRow.data
Layout.fillWidth: true
spacing: 10
RowLayout {
Layout.fillWidth: true
spacing: 16
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Label {
visible: eyebrow !== ""
text: eyebrow.toUpperCase()
color: Theme.muted
font.pixelSize: 10
font.letterSpacing: 1.3
font.weight: Font.DemiBold
font.family: Theme.sansFamily
}
Label {
text: title
color: Theme.ink
font.pixelSize: 27
font.family: Theme.displayFamily
font.letterSpacing: -0.6
}
Label {
visible: caption !== ""
text: caption
color: Theme.muted
font.pixelSize: 13
font.family: Theme.sansFamily
Layout.fillWidth: true
wrapMode: Text.WordWrap
}
}
RowLayout { id: actionRow; spacing: 8; Layout.alignment: Qt.AlignRight | Qt.AlignVCenter }
}
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.rule }
}
@@ -1,39 +0,0 @@
// A pill in a preset switcher: filled ember when it is the one the live
// settings currently match, outlined otherwise. Clicking any pill applies it
// at once — there is no separate "Apply" step. Shared by SettingsPage (the
// full switcher, with the numbers editable below it) and TimerPage (a quick
// way to change the length of the *next* interval without leaving the dial).
import QtQuick
import QtQuick.Controls
import ".."
Rectangle {
id: chip
property string text: ""
property bool active: false
signal clicked()
implicitWidth: label.implicitWidth + 28
implicitHeight: 32
radius: 16
color: chip.active ? Theme.ember : (chipHover.hovered ? Qt.alpha(Theme.ember, 0.10) : "transparent")
border.width: chip.active ? 0 : 1
border.color: chipHover.hovered ? Theme.ember : Theme.rule
Behavior on color { ColorAnimation { duration: 110 } }
Label {
id: label
anchors.centerIn: parent
text: chip.text
color: chip.active ? "white" : Theme.ink
font.pixelSize: 12
font.weight: chip.active ? Font.DemiBold : Font.Medium
font.family: Theme.sansFamily
}
HoverHandler { id: chipHover; cursorShape: Qt.PointingHandCursor }
TapHandler {
grabPermissions: PointerHandler.TakeOverForbidden
onTapped: chip.clicked()
}
}
@@ -1,39 +0,0 @@
// Transient messages, bottom centre, on an ink slab.
import QtQuick
import QtQuick.Controls
import ".."
Popup {
id: snackbar
property string message: ""
function show(text) {
message = text
open()
timer.restart()
}
x: Math.round((parent.width - width) / 2)
y: parent.height - height - 28
width: Math.min(parent.width - 48, Math.max(240, label.implicitWidth + 36))
padding: 14
modal: false
focus: false
closePolicy: Popup.NoAutoClose
background: Rectangle {
color: Theme.ink
radius: 10
}
contentItem: Label {
id: label
text: snackbar.message
color: Theme.paper
font.pixelSize: 13
font.family: Theme.sansFamily
wrapMode: Text.WordWrap
}
Timer { id: timer; interval: 5000; onTriggered: snackbar.close() }
}
@@ -1,44 +0,0 @@
// A number and its name. The figure is set in the display serif, which is what
// makes a screen of statistics read like a page rather than a readout.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ".."
ColumnLayout {
property string label: ""
property string value: "—"
property string caption: ""
property color accent: Theme.ink
property int size: 30
spacing: 2
Layout.fillWidth: true
Label {
text: label.toUpperCase()
color: Theme.muted
font.pixelSize: 10
font.letterSpacing: 1.2
font.weight: Font.DemiBold
font.family: Theme.sansFamily
}
Label {
text: value
color: accent
font.pixelSize: size
font.family: Theme.displayFamily
font.letterSpacing: -0.5
}
Label {
visible: caption !== ""
text: caption
color: Theme.faint
font.pixelSize: 11
font.family: Theme.sansFamily
Layout.fillWidth: true
elide: Text.ElideRight
wrapMode: Text.WordWrap
}
}
@@ -1,120 +0,0 @@
// The dial.
//
// A hairline ring with one tick per minute inside it: the ticks light up as the
// minutes are spent, so the dial reads as "how much is left" at a glance and as
// "how long was this?" on a second look. The time is the hero, set in the
// display serif.
import QtQuick
import QtQuick.Controls
import ".."
Item {
id: dial
property real progress: 0 // 0..1 elapsed
property string clockText: "00:00"
property string label: ""
property string sublabel: ""
property color accent: Theme.work
property bool paused: false
property bool idle: false
property int minutes: 25
implicitWidth: 280
implicitHeight: 280
onProgressChanged: canvas.requestPaint()
onAccentChanged: canvas.requestPaint()
onPausedChanged: canvas.requestPaint()
onIdleChanged: canvas.requestPaint()
Canvas {
id: canvas
anchors.fill: parent
antialiasing: true
onPaint: {
var ctx = getContext("2d")
ctx.reset()
var c = Math.min(width, height) / 2
var radius = c - 26
// The ring.
ctx.lineWidth = 1.5
ctx.strokeStyle = Theme.rule
ctx.beginPath()
ctx.arc(c, c, radius, 0, Math.PI * 2)
ctx.stroke()
// Minute ticks, inside the ring.
var count = dial.minutes <= 60 ? Math.max(1, dial.minutes) : Math.round(dial.minutes / 5)
var spent = Math.floor(count * Math.max(0, Math.min(1, dial.progress)))
for (var i = 0; i < count; i++) {
var angle = (i / count) * Math.PI * 2 - Math.PI / 2
var major = i % 5 === 0
var outer = radius - 7
var inner = outer - (major ? 9 : 5)
ctx.beginPath()
ctx.lineWidth = major ? 1.4 : 1
ctx.strokeStyle = (i < spent && !dial.idle) ? dial.accent : Theme.rule
ctx.moveTo(c + inner * Math.cos(angle), c + inner * Math.sin(angle))
ctx.lineTo(c + outer * Math.cos(angle), c + outer * Math.sin(angle))
ctx.stroke()
}
// The remaining arc.
if (!dial.idle) {
var remaining = 1 - Math.max(0, Math.min(1, dial.progress))
if (remaining > 0) {
ctx.beginPath()
ctx.lineWidth = 2.5
ctx.lineCap = "round"
ctx.globalAlpha = dial.paused ? 0.4 : 1
ctx.strokeStyle = dial.accent
ctx.arc(c, c, radius, -Math.PI / 2, -Math.PI / 2 + remaining * Math.PI * 2)
ctx.stroke()
}
}
}
}
Column {
anchors.centerIn: parent
spacing: 6
width: parent.width * 0.7
Label {
width: parent.width
text: dial.label.toUpperCase()
color: dial.idle ? Theme.muted : dial.accent
font.pixelSize: 10
font.letterSpacing: 1.4
font.weight: Font.DemiBold
font.family: Theme.sansFamily
horizontalAlignment: Text.AlignHCenter
}
Label {
width: parent.width
text: dial.clockText
color: Theme.ink
opacity: dial.paused ? 0.55 : 1
font.pixelSize: Math.round(dial.width * 0.21)
font.family: Theme.displayFamily
font.letterSpacing: -1.5
horizontalAlignment: Text.AlignHCenter
Behavior on opacity { NumberAnimation { duration: 180 } }
}
Label {
width: parent.width
visible: dial.sublabel !== ""
text: dial.sublabel
color: Theme.muted
font.pixelSize: 12
font.family: Theme.sansFamily
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
maximumLineCount: 2
wrapMode: Text.WordWrap
}
}
}