add blocker presets

This commit is contained in:
timka123 committed 2026-10-19 11:54:15 +03:00
1 parent 2be074f191
commit 772fdbfded
12 files changed
+422 -79

No files matched your search

+27 -2
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from sqlalchemy import create_engine
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import env_settings
@@ -32,11 +32,36 @@ engine = create_engine(env_settings().database_url, **_engine_kwargs(env_setting
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
# Columns a refactor later dropped from the model but, with no migration tool
# in this project, left behind on any database that was already running —
# still `NOT NULL` with no server-side default, so every insert that no
# longer sets them fails. `create_all` only ever adds; this is the other half.
_DEAD_COLUMNS: dict[str, tuple[str, ...]] = {
"tasks": ("completed_here",),
}
def _drop_dead_columns() -> None:
inspector = inspect(engine)
existing_tables = set(inspector.get_table_names())
with engine.begin() as conn:
for table, columns in _DEAD_COLUMNS.items():
if table not in existing_tables:
continue
existing_columns = {c["name"] for c in inspector.get_columns(table)}
for column in columns:
if column in existing_columns:
conn.execute(text(f'ALTER TABLE {table} DROP COLUMN {column}'))
def init_db() -> None:
"""Create any missing tables. The schema is additive, so this is enough."""
"""Create any missing tables. The schema is additive, so this is enough —
plus the one-time cleanup above for columns a refactor removed from the
model."""
from app import models # noqa: F401 (register the mappers)
Base.metadata.create_all(bind=engine)
_drop_dead_columns()
@contextmanager
+19
View File
@@ -216,6 +216,25 @@ def extend(session: Session, minutes: int) -> PomodoroSession:
return row
def rescale_current(session: Session, settings: AppSettings) -> PomodoroSession | None:
"""Resize the running/paused interval to match a just-saved duration.
Without this, a preset (or any Focus/Short/Long edit) only takes effect
from the *next* interval — the one already ticking keeps whatever length
it was born with. `elapsed_seconds` is wall-clock, not stored, so this
only has to move `planned_seconds`; time already spent still counts, it
is just measured against the new total.
"""
row = current_session(session)
if row is None:
return None
planned = duration_for(settings, row.kind) # type: ignore[arg-type]
if planned != row.planned_seconds:
row.planned_seconds = planned
session.commit()
return row
def attach_task(session: Session, task_id: int | None) -> PomodoroSession:
row = _require_active(session)
if task_id is None:
+9 -1
View File
@@ -19,6 +19,7 @@ from sqlalchemy.orm import Session
from app.config import env_settings
from app.models import SettingsRow
from app.services import pomodoro
from app.settings_schema import SECRET_FIELDS, SECRET_MASK, AppSettings
_SETTINGS_ID = 1
@@ -193,4 +194,11 @@ def apply_patch(session: Session, patch: dict[str, Any]) -> AppSettings:
current = load_settings(session).model_dump(mode="json")
patch = unmask_secrets(current, patch)
merged = _merge(current, patch)
return save_settings(session, AppSettings.model_validate(merged))
updated = save_settings(session, AppSettings.model_validate(merged))
# A changed Focus/Short/Long minute (directly, or via a preset) resizes
# whatever interval is already running or paused — see
# `pomodoro.rescale_current`. Cheap and a no-op when nothing moved, so it
# is simplest to always run it rather than detect which keys changed.
pomodoro.rescale_current(session, updated)
return updated
+85
View File
@@ -0,0 +1,85 @@
"""Regression test for a schema-drift bug: a database created by an older
build of the app still had a `completed_here NOT NULL` column on `tasks`
that a later refactor dropped from the model. `create_all` never alters an
existing table, so every task insert on such a database failed with
IntegrityError: null value in column "completed_here" ... violates
not-null constraint
which, since task refresh runs from both the scheduler and `/api/tasks`
endpoints, looked like "everything 500s". `app.db.init_db` now reconciles
known-dead columns after `create_all`; this pins that behaviour down.
"""
from __future__ import annotations
from sqlalchemy import text
from app.db import SessionLocal, engine, init_db
from app.models import TaskRecord
def _simulate_pre_refactor_database() -> None:
"""Recreate the exact drift: today's `tasks` table, plus the long-gone
`completed_here` column, `NOT NULL` and with no database-side default —
just like a database that has been running since before the column was
removed from the model.
"""
with engine.begin() as conn:
conn.execute(text("DROP TABLE IF EXISTS tasks"))
conn.execute(
text(
"""
CREATE TABLE tasks (
id INTEGER PRIMARY KEY,
source VARCHAR(32) NOT NULL,
external_id VARCHAR(128) NOT NULL,
title TEXT NOT NULL,
description TEXT,
url TEXT,
project TEXT,
status VARCHAR(64),
priority VARCHAR(32),
due_date DATE,
start_date DATE,
labels JSON,
raw JSON,
is_done BOOLEAN NOT NULL DEFAULT 0,
completed_at DATETIME,
completed_date DATE,
completed_here BOOLEAN NOT NULL,
estimated_pomodoros INTEGER,
pinned BOOLEAN NOT NULL DEFAULT 0,
first_seen_at DATETIME NOT NULL,
last_seen_at DATETIME NOT NULL,
stale BOOLEAN NOT NULL DEFAULT 0
)
"""
)
)
def test_init_db_drops_the_dead_not_null_column(session):
_simulate_pre_refactor_database()
init_db()
db = SessionLocal()
try:
# This is exactly what `tasks_service._upsert` + `refresh_tasks` do on
# a newly-seen task: insert a row without ever mentioning the column
# the model no longer has.
db.add(TaskRecord(source="jira", external_id="VPN-48", title="Renew VPN"))
db.commit() # pre-fix: IntegrityError (null value in "completed_here")
stored = db.query(TaskRecord).filter_by(external_id="VPN-48").one()
assert stored.title == "Renew VPN"
finally:
db.close()
def test_init_db_is_a_no_op_once_the_column_is_already_gone(session):
"""Running it twice (every boot does) must not blow up when there is
nothing left to drop."""
init_db()
init_db()
+25 -1
View File
@@ -2,10 +2,34 @@
from __future__ import annotations
from app.services import settings_service
from app.services import pomodoro, settings_service
from app.settings_schema import SECRET_MASK
def test_a_preset_change_resizes_the_running_interval(session, settings):
row = pomodoro.start(session, settings, kind="work")
assert row.planned_seconds == settings.pomodoro.work_minutes * 60
updated = settings_service.apply_patch(
session, {"pomodoro": {"work_minutes": 85, "short_break_minutes": 10}}
)
current = pomodoro.current_session(session)
assert current.id == row.id
assert current.planned_seconds == 85 * 60
# Elapsed time is wall-clock, not stored — resizing never has to touch it.
assert pomodoro.elapsed_seconds(current) >= 0
assert updated.pomodoro.work_minutes == 85
def test_a_patch_unrelated_to_durations_leaves_the_running_interval_alone(session, settings):
row = pomodoro.start(session, settings, kind="work")
settings_service.apply_patch(session, {"pomodoro": {"daily_goal_pomodoros": 12}})
current = pomodoro.current_session(session)
assert current.id == row.id
assert current.planned_seconds == row.planned_seconds
def test_patch_merges_without_clobbering_siblings(session):
settings_service.apply_patch(session, {"pomodoro": {"work_minutes": 50}})
updated = settings_service.apply_patch(
+40
View File
@@ -261,6 +261,24 @@ class Controller(QObject):
def settings(self) -> dict[str, Any]:
return self._settings
@pyqtProperty(int, notify=settingsChanged)
def activePresetIndex(self) -> int:
"""Which saved preset the live Focus/Short/Long minutes match, or -1.
One source of truth for SettingsPage (the full switcher, with the
numbers editable below it) and TimerPage (a quick switcher next to
the dial) so the two never compute "which preset is this" differently.
"""
pomodoro = self._settings.get("pomodoro", {})
for index, preset in enumerate(pomodoro.get("presets", [])):
if (
preset.get("work_minutes") == pomodoro.get("work_minutes")
and preset.get("short_break_minutes") == pomodoro.get("short_break_minutes")
and preset.get("long_break_minutes") == pomodoro.get("long_break_minutes")
):
return index
return -1
@pyqtProperty(bool, notify=blockerChanged)
def blockerActive(self) -> bool:
return bool(self._policy.get("active"))
@@ -426,6 +444,28 @@ class Controller(QObject):
def patchSettings(self, patch: dict[str, Any]) -> None:
self._send("patch_settings", {"patch": patch})
@pyqtSlot(int)
def applyPreset(self, index: int) -> None:
"""Copy a saved preset's minutes onto the live timer settings.
The backend resizes whatever interval is already running or paused
to match, so this takes effect at once — not just on the next one.
"""
presets = self._settings.get("pomodoro", {}).get("presets", [])
if not (0 <= index < len(presets)):
return
preset = presets[index]
self.patchSettings(
{
"pomodoro": {
"work_minutes": preset["work_minutes"],
"short_break_minutes": preset["short_break_minutes"],
"long_break_minutes": preset["long_break_minutes"],
"cycles_before_long_break": preset["cycles_before_long_break"],
}
}
)
@pyqtSlot(str, "QVariant")
def setConfig(self, key: str, value: Any) -> None:
"""Change a desktop-local setting and apply it immediately."""
+31 -73
View File
@@ -29,27 +29,21 @@ ScrollView {
controller.patchSettings({ notifications: { telegram: patch } })
}
// Presets are a saved list on the server (`pomodoro.presets`), each one a
// name plus its own work/short/long minutes — editable here, not baked
// into the UI. `_merge` on the backend replaces list fields wholesale, so
// editing one preset's number means resending the whole list.
function patchPreset(index, key, value) {
if (!page.pomodoro || !page.pomodoro.presets) return
// The Focus/Short break/Long break fields always show and edit the live
// settings. When they still match a saved preset, the edit also rewrites
// that preset's own numbers, so picking it again later gets you back to
// where you left off rather than to some older saved value.
function patchDuration(key, value) {
if (!page.pomodoro) return
var patch = {}; patch[key] = value
var idx = controller.activePresetIndex
if (idx >= 0 && page.pomodoro.presets) {
var presets = page.pomodoro.presets.map(function (p) { return Object.assign({}, p) })
presets[index][key] = value
controller.patchSettings({ pomodoro: { presets: presets } })
presets[idx] = Object.assign(presets[idx], patch)
controller.patchSettings({ pomodoro: Object.assign({ presets: presets }, patch) })
} else {
controller.patchSettings({ pomodoro: patch })
}
function applyPreset(index) {
if (!page.pomodoro || !page.pomodoro.presets) return
var preset = page.pomodoro.presets[index]
controller.patchSettings({
pomodoro: {
work_minutes: preset.work_minutes,
short_break_minutes: preset.short_break_minutes,
long_break_minutes: preset.long_break_minutes,
cycles_before_long_break: preset.cycles_before_long_break
}
})
}
component Field: ColumnLayout {
@@ -127,69 +121,33 @@ ScrollView {
spacing: 12
SectionTitle { text: "Timer" }
// Presets: a saved list of work/short/long combos. "Apply" copies
// one onto the live settings below; the numbers on each row are
// the preset's own and edit it in place.
ColumnLayout {
// The preset switcher: pick one and it applies at once. The fields
// below always belong to whichever one is lit — there is nothing
// to show for the others until you switch to them.
RowLayout {
Layout.fillWidth: true
spacing: 8
Repeater {
model: page.pomodoro && page.pomodoro.presets ? page.pomodoro.presets : []
RowLayout {
id: presetRow
delegate: PresetChip {
required property var modelData
required property int index
Layout.fillWidth: true
spacing: 16
readonly property bool isActive: page.pomodoro
&& page.pomodoro.work_minutes === modelData.work_minutes
&& page.pomodoro.short_break_minutes === modelData.short_break_minutes
&& page.pomodoro.long_break_minutes === modelData.long_break_minutes
text: modelData.name
active: controller.activePresetIndex === index
onClicked: controller.applyPreset(index)
}
}
Label {
text: presetRow.modelData.name
color: presetRow.isActive ? Theme.ember : Theme.ink
font.pixelSize: 13
font.weight: presetRow.isActive ? Font.DemiBold : Font.Medium
visible: controller.activePresetIndex === -1
text: "Custom"
color: Theme.muted
font.pixelSize: 12
font.family: Theme.sansFamily
Layout.preferredWidth: 70
}
Field {
label: "Focus"
SpinBox {
from: 1; to: 240
value: presetRow.modelData.work_minutes
onValueModified: page.patchPreset(presetRow.index, "work_minutes", value)
}
}
Field {
label: "Short break"
SpinBox {
from: 1; to: 120
value: presetRow.modelData.short_break_minutes
onValueModified: page.patchPreset(presetRow.index, "short_break_minutes", value)
}
}
Field {
label: "Long break"
SpinBox {
from: 1; to: 240
value: presetRow.modelData.long_break_minutes
onValueModified: page.patchPreset(presetRow.index, "long_break_minutes", value)
}
}
GhostButton {
text: presetRow.isActive ? "Active" : "Apply"
enabled: !presetRow.isActive
onClicked: page.applyPreset(presetRow.index)
leftPadding: 6
}
Item { Layout.fillWidth: true }
}
}
}
Flow {
Layout.fillWidth: true
@@ -199,7 +157,7 @@ ScrollView {
SpinBox {
from: 1; to: 240
value: page.pomodoro ? page.pomodoro.work_minutes : 25
onValueModified: page.patchPomodoro("work_minutes", value)
onValueModified: page.patchDuration("work_minutes", value)
}
}
Field {
@@ -207,7 +165,7 @@ ScrollView {
SpinBox {
from: 1; to: 120
value: page.pomodoro ? page.pomodoro.short_break_minutes : 5
onValueModified: page.patchPomodoro("short_break_minutes", value)
onValueModified: page.patchDuration("short_break_minutes", value)
}
}
Field {
@@ -215,7 +173,7 @@ ScrollView {
SpinBox {
from: 1; to: 240
value: page.pomodoro ? page.pomodoro.long_break_minutes : 15
onValueModified: page.patchPomodoro("long_break_minutes", value)
onValueModified: page.patchDuration("long_break_minutes", value)
}
}
Field {
+23
View File
@@ -102,6 +102,29 @@ ScrollView {
idle: !controller.timerEnabled || !controller.running
}
// Switching here takes effect at once — it resizes whatever
// is already running or paused too, not just the next one.
RowLayout {
visible: controller.timerEnabled
&& controller.settings.pomodoro
&& controller.settings.pomodoro.presets
&& controller.settings.pomodoro.presets.length > 0
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: 14
spacing: 8
Repeater {
model: controller.settings.pomodoro ? controller.settings.pomodoro.presets : []
delegate: PresetChip {
required property var modelData
required property int index
text: modelData.name
active: controller.activePresetIndex === index
onClicked: controller.applyPreset(index)
}
}
}
RowLayout {
visible: controller.timerEnabled && controller.running
Layout.alignment: Qt.AlignHCenter
@@ -0,0 +1,39 @@
// 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()
}
}
+7
View File
@@ -166,6 +166,13 @@ export interface AppSettings {
short_break_minutes: number;
long_break_minutes: number;
cycles_before_long_break: number;
presets: {
name: string;
work_minutes: number;
short_break_minutes: number;
long_break_minutes: number;
cycles_before_long_break: number;
}[];
auto_start_breaks: boolean;
auto_start_work: boolean;
daily_goal_pomodoros: number;
+64 -1
View File
@@ -17,6 +17,8 @@ import {
Stack,
Switch,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
} from "@mui/material";
@@ -44,6 +46,48 @@ export default function SettingsPage({ onToast }: { onToast: (message: string) =
if (!settings) return <Typography>Loading…</Typography>;
// Which saved preset the live Focus/Short/Long minutes currently match, if
// any — "Custom" the moment they drift from every one of them.
const presets = settings.pomodoro.presets;
const activePresetIndex = presets.findIndex(
(preset) =>
preset.work_minutes === settings.pomodoro.work_minutes &&
preset.short_break_minutes === settings.pomodoro.short_break_minutes &&
preset.long_break_minutes === settings.pomodoro.long_break_minutes,
);
const applyPreset = (index: number) => {
const preset = presets[index];
void patch(
{
pomodoro: {
work_minutes: preset.work_minutes,
short_break_minutes: preset.short_break_minutes,
long_break_minutes: preset.long_break_minutes,
cycles_before_long_break: preset.cycles_before_long_break,
},
},
`${preset.name} applied`,
);
};
// Editing Focus/Short/Long always changes the live settings; when they
// still match a saved preset it also rewrites that preset's own numbers,
// so applying it again later lands where you left off, not on a stale value.
const patchDuration = (
key: "work_minutes" | "short_break_minutes" | "long_break_minutes",
value: number,
) => {
if (activePresetIndex === -1) {
void patch({ pomodoro: { [key]: value } });
return;
}
const updated = presets.map((preset, index) =>
index === activePresetIndex ? { ...preset, [key]: value } : preset,
);
void patch({ pomodoro: { presets: updated, [key]: value } });
};
const testSource = async (name: string) => {
setTesting(name);
try {
@@ -65,6 +109,21 @@ export default function SettingsPage({ onToast }: { onToast: (message: string) =
/>
<Stack spacing={5}>
<Section title="Timer" subtitle="Shared with the desktop app and the Telegram bot.">
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2, flexWrap: "wrap", rowGap: 1 }}>
<ToggleButtonGroup
size="small"
exclusive
value={activePresetIndex === -1 ? null : activePresetIndex}
onChange={(_event, value) => value !== null && applyPreset(value as number)}
>
{presets.map((preset, index) => (
<ToggleButton key={preset.name} value={index}>
{preset.name}
</ToggleButton>
))}
</ToggleButtonGroup>
{activePresetIndex === -1 && <Chip size="small" variant="outlined" label="Custom" />}
</Stack>
<Grid container spacing={2}>
{(
[
@@ -81,7 +140,11 @@ export default function SettingsPage({ onToast }: { onToast: (message: string) =
value={settings.pomodoro[key] as number}
min={min}
max={max}
onCommit={(value) => void patch({ pomodoro: { [key]: value } })}
onCommit={(value) =>
key === "work_minutes" || key === "short_break_minutes" || key === "long_break_minutes"
? patchDuration(key, value)
: void patch({ pomodoro: { [key]: value } })
}
/>
</Grid>
))}
+53 -1
View File
@@ -22,6 +22,8 @@ import {
MenuItem,
Stack,
TextField,
ToggleButton,
ToggleButtonGroup,
Tooltip,
Typography,
} from "@mui/material";
@@ -136,6 +138,39 @@ export default function TimerPage({
const total = current?.planned_seconds ?? (state ? state.durations[kind] * 60 : 1500);
const accent = kindColor(kind, palette);
// Which saved preset the live Focus/Short/Long minutes match, if any.
const presets = settings?.pomodoro.presets ?? [];
const activePresetIndex = settings
? presets.findIndex(
(preset) =>
preset.work_minutes === settings.pomodoro.work_minutes &&
preset.short_break_minutes === settings.pomodoro.short_break_minutes &&
preset.long_break_minutes === settings.pomodoro.long_break_minutes,
)
: -1;
// The backend resizes whatever interval is already running or paused to
// match, so a fresh `/api/timer/state` right away makes it feel instant
// instead of waiting out this page's own 10s poll.
const applyPreset = async (index: number) => {
const preset = presets[index];
if (!preset) return;
try {
await save({
pomodoro: {
work_minutes: preset.work_minutes,
short_break_minutes: preset.short_break_minutes,
long_break_minutes: preset.long_break_minutes,
cycles_before_long_break: preset.cycles_before_long_break,
},
});
await timer.reload();
onToast(`${preset.name} applied`);
} catch (exc) {
onToast(exc instanceof Error ? exc.message : String(exc));
}
};
return (
<>
<PageHeader
@@ -220,8 +255,25 @@ export default function TimerPage({
idle={!timerEnabled || !current}
/>
{timerEnabled && presets.length > 0 && (
<Stack direction="row" justifyContent="center" sx={{ mt: 3 }}>
<ToggleButtonGroup
size="small"
exclusive
value={activePresetIndex === -1 ? null : activePresetIndex}
onChange={(_event, value) => value !== null && void applyPreset(value as number)}
>
{presets.map((preset, index) => (
<ToggleButton key={preset.name} value={index}>
{preset.name}
</ToggleButton>
))}
</ToggleButtonGroup>
</Stack>
)}
{current && timerEnabled && (
<Stack direction="row" spacing={1} justifyContent="center" sx={{ mt: 3 }}>
<Stack direction="row" spacing={1} justifyContent="center" sx={{ mt: 2 }}>
<Tooltip title="Add five minutes">
<IconButton size="small" disabled={busy} onClick={() => void call("/api/timer/extend", { minutes: 5 })}>
<AddAlarmOutlinedIcon fontSize="small" />