add blocker presets
This commit is contained in:
12 files changed
+426
-83
No files matched your search
+27
-2
@@ -11,7 +11,7 @@ from __future__ import annotations
|
|||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, inspect, text
|
||||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||||
|
|
||||||
from app.config import env_settings
|
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)
|
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:
|
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)
|
from app import models # noqa: F401 (register the mappers)
|
||||||
|
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
_drop_dead_columns()
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
|
|||||||
@@ -216,6 +216,25 @@ def extend(session: Session, minutes: int) -> PomodoroSession:
|
|||||||
return row
|
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:
|
def attach_task(session: Session, task_id: int | None) -> PomodoroSession:
|
||||||
row = _require_active(session)
|
row = _require_active(session)
|
||||||
if task_id is None:
|
if task_id is None:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.config import env_settings
|
from app.config import env_settings
|
||||||
from app.models import SettingsRow
|
from app.models import SettingsRow
|
||||||
|
from app.services import pomodoro
|
||||||
from app.settings_schema import SECRET_FIELDS, SECRET_MASK, AppSettings
|
from app.settings_schema import SECRET_FIELDS, SECRET_MASK, AppSettings
|
||||||
|
|
||||||
_SETTINGS_ID = 1
|
_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")
|
current = load_settings(session).model_dump(mode="json")
|
||||||
patch = unmask_secrets(current, patch)
|
patch = unmask_secrets(current, patch)
|
||||||
merged = _merge(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
|
||||||
@@ -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()
|
||||||
@@ -2,10 +2,34 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from app.services import settings_service
|
from app.services import pomodoro, settings_service
|
||||||
from app.settings_schema import SECRET_MASK
|
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):
|
def test_patch_merges_without_clobbering_siblings(session):
|
||||||
settings_service.apply_patch(session, {"pomodoro": {"work_minutes": 50}})
|
settings_service.apply_patch(session, {"pomodoro": {"work_minutes": 50}})
|
||||||
updated = settings_service.apply_patch(
|
updated = settings_service.apply_patch(
|
||||||
|
|||||||
@@ -261,6 +261,24 @@ class Controller(QObject):
|
|||||||
def settings(self) -> dict[str, Any]:
|
def settings(self) -> dict[str, Any]:
|
||||||
return self._settings
|
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)
|
@pyqtProperty(bool, notify=blockerChanged)
|
||||||
def blockerActive(self) -> bool:
|
def blockerActive(self) -> bool:
|
||||||
return bool(self._policy.get("active"))
|
return bool(self._policy.get("active"))
|
||||||
@@ -426,6 +444,28 @@ class Controller(QObject):
|
|||||||
def patchSettings(self, patch: dict[str, Any]) -> None:
|
def patchSettings(self, patch: dict[str, Any]) -> None:
|
||||||
self._send("patch_settings", {"patch": patch})
|
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")
|
@pyqtSlot(str, "QVariant")
|
||||||
def setConfig(self, key: str, value: Any) -> None:
|
def setConfig(self, key: str, value: Any) -> None:
|
||||||
"""Change a desktop-local setting and apply it immediately."""
|
"""Change a desktop-local setting and apply it immediately."""
|
||||||
|
|||||||
@@ -29,27 +29,21 @@ ScrollView {
|
|||||||
controller.patchSettings({ notifications: { telegram: patch } })
|
controller.patchSettings({ notifications: { telegram: patch } })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Presets are a saved list on the server (`pomodoro.presets`), each one a
|
// The Focus/Short break/Long break fields always show and edit the live
|
||||||
// name plus its own work/short/long minutes — editable here, not baked
|
// settings. When they still match a saved preset, the edit also rewrites
|
||||||
// into the UI. `_merge` on the backend replaces list fields wholesale, so
|
// that preset's own numbers, so picking it again later gets you back to
|
||||||
// editing one preset's number means resending the whole list.
|
// where you left off rather than to some older saved value.
|
||||||
function patchPreset(index, key, value) {
|
function patchDuration(key, value) {
|
||||||
if (!page.pomodoro || !page.pomodoro.presets) return
|
if (!page.pomodoro) return
|
||||||
var presets = page.pomodoro.presets.map(function (p) { return Object.assign({}, p) })
|
var patch = {}; patch[key] = value
|
||||||
presets[index][key] = value
|
var idx = controller.activePresetIndex
|
||||||
controller.patchSettings({ pomodoro: { presets: presets } })
|
if (idx >= 0 && page.pomodoro.presets) {
|
||||||
}
|
var presets = page.pomodoro.presets.map(function (p) { return Object.assign({}, p) })
|
||||||
function applyPreset(index) {
|
presets[idx] = Object.assign(presets[idx], patch)
|
||||||
if (!page.pomodoro || !page.pomodoro.presets) return
|
controller.patchSettings({ pomodoro: Object.assign({ presets: presets }, patch) })
|
||||||
var preset = page.pomodoro.presets[index]
|
} else {
|
||||||
controller.patchSettings({
|
controller.patchSettings({ pomodoro: 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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
component Field: ColumnLayout {
|
component Field: ColumnLayout {
|
||||||
@@ -127,68 +121,32 @@ ScrollView {
|
|||||||
spacing: 12
|
spacing: 12
|
||||||
SectionTitle { text: "Timer" }
|
SectionTitle { text: "Timer" }
|
||||||
|
|
||||||
// Presets: a saved list of work/short/long combos. "Apply" copies
|
// The preset switcher: pick one and it applies at once. The fields
|
||||||
// one onto the live settings below; the numbers on each row are
|
// below always belong to whichever one is lit — there is nothing
|
||||||
// the preset's own and edit it in place.
|
// to show for the others until you switch to them.
|
||||||
ColumnLayout {
|
RowLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
spacing: 8
|
spacing: 8
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: page.pomodoro && page.pomodoro.presets ? page.pomodoro.presets : []
|
model: page.pomodoro && page.pomodoro.presets ? page.pomodoro.presets : []
|
||||||
|
delegate: PresetChip {
|
||||||
RowLayout {
|
|
||||||
id: presetRow
|
|
||||||
required property var modelData
|
required property var modelData
|
||||||
required property int index
|
required property int index
|
||||||
Layout.fillWidth: true
|
text: modelData.name
|
||||||
spacing: 16
|
active: controller.activePresetIndex === index
|
||||||
|
onClicked: controller.applyPreset(index)
|
||||||
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
|
|
||||||
|
|
||||||
Label {
|
|
||||||
text: presetRow.modelData.name
|
|
||||||
color: presetRow.isActive ? Theme.ember : Theme.ink
|
|
||||||
font.pixelSize: 13
|
|
||||||
font.weight: presetRow.isActive ? Font.DemiBold : Font.Medium
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Label {
|
||||||
|
visible: controller.activePresetIndex === -1
|
||||||
|
text: "Custom"
|
||||||
|
color: Theme.muted
|
||||||
|
font.pixelSize: 12
|
||||||
|
font.family: Theme.sansFamily
|
||||||
|
leftPadding: 6
|
||||||
|
}
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
Flow {
|
Flow {
|
||||||
@@ -199,7 +157,7 @@ ScrollView {
|
|||||||
SpinBox {
|
SpinBox {
|
||||||
from: 1; to: 240
|
from: 1; to: 240
|
||||||
value: page.pomodoro ? page.pomodoro.work_minutes : 25
|
value: page.pomodoro ? page.pomodoro.work_minutes : 25
|
||||||
onValueModified: page.patchPomodoro("work_minutes", value)
|
onValueModified: page.patchDuration("work_minutes", value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Field {
|
Field {
|
||||||
@@ -207,7 +165,7 @@ ScrollView {
|
|||||||
SpinBox {
|
SpinBox {
|
||||||
from: 1; to: 120
|
from: 1; to: 120
|
||||||
value: page.pomodoro ? page.pomodoro.short_break_minutes : 5
|
value: page.pomodoro ? page.pomodoro.short_break_minutes : 5
|
||||||
onValueModified: page.patchPomodoro("short_break_minutes", value)
|
onValueModified: page.patchDuration("short_break_minutes", value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Field {
|
Field {
|
||||||
@@ -215,7 +173,7 @@ ScrollView {
|
|||||||
SpinBox {
|
SpinBox {
|
||||||
from: 1; to: 240
|
from: 1; to: 240
|
||||||
value: page.pomodoro ? page.pomodoro.long_break_minutes : 15
|
value: page.pomodoro ? page.pomodoro.long_break_minutes : 15
|
||||||
onValueModified: page.patchPomodoro("long_break_minutes", value)
|
onValueModified: page.patchDuration("long_break_minutes", value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Field {
|
Field {
|
||||||
|
|||||||
@@ -102,6 +102,29 @@ ScrollView {
|
|||||||
idle: !controller.timerEnabled || !controller.running
|
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 {
|
RowLayout {
|
||||||
visible: controller.timerEnabled && controller.running
|
visible: controller.timerEnabled && controller.running
|
||||||
Layout.alignment: Qt.AlignHCenter
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -166,6 +166,13 @@ export interface AppSettings {
|
|||||||
short_break_minutes: number;
|
short_break_minutes: number;
|
||||||
long_break_minutes: number;
|
long_break_minutes: number;
|
||||||
cycles_before_long_break: 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_breaks: boolean;
|
||||||
auto_start_work: boolean;
|
auto_start_work: boolean;
|
||||||
daily_goal_pomodoros: number;
|
daily_goal_pomodoros: number;
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
TextField,
|
TextField,
|
||||||
|
ToggleButton,
|
||||||
|
ToggleButtonGroup,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
|
||||||
@@ -44,6 +46,48 @@ export default function SettingsPage({ onToast }: { onToast: (message: string) =
|
|||||||
|
|
||||||
if (!settings) return <Typography>Loading…</Typography>;
|
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) => {
|
const testSource = async (name: string) => {
|
||||||
setTesting(name);
|
setTesting(name);
|
||||||
try {
|
try {
|
||||||
@@ -65,6 +109,21 @@ export default function SettingsPage({ onToast }: { onToast: (message: string) =
|
|||||||
/>
|
/>
|
||||||
<Stack spacing={5}>
|
<Stack spacing={5}>
|
||||||
<Section title="Timer" subtitle="Shared with the desktop app and the Telegram bot.">
|
<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}>
|
<Grid container spacing={2}>
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
@@ -81,7 +140,11 @@ export default function SettingsPage({ onToast }: { onToast: (message: string) =
|
|||||||
value={settings.pomodoro[key] as number}
|
value={settings.pomodoro[key] as number}
|
||||||
min={min}
|
min={min}
|
||||||
max={max}
|
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>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import {
|
|||||||
MenuItem,
|
MenuItem,
|
||||||
Stack,
|
Stack,
|
||||||
TextField,
|
TextField,
|
||||||
|
ToggleButton,
|
||||||
|
ToggleButtonGroup,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
@@ -136,6 +138,39 @@ export default function TimerPage({
|
|||||||
const total = current?.planned_seconds ?? (state ? state.durations[kind] * 60 : 1500);
|
const total = current?.planned_seconds ?? (state ? state.durations[kind] * 60 : 1500);
|
||||||
const accent = kindColor(kind, palette);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -220,8 +255,25 @@ export default function TimerPage({
|
|||||||
idle={!timerEnabled || !current}
|
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 && (
|
{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">
|
<Tooltip title="Add five minutes">
|
||||||
<IconButton size="small" disabled={busy} onClick={() => void call("/api/timer/extend", { minutes: 5 })}>
|
<IconButton size="small" disabled={busy} onClick={() => void call("/api/timer/extend", { minutes: 5 })}>
|
||||||
<AddAlarmOutlinedIcon fontSize="small" />
|
<AddAlarmOutlinedIcon fontSize="small" />
|
||||||
|
|||||||
Reference in new issue
Block a user