diff --git a/backend/app/db.py b/backend/app/db.py
index bc37a0f..ca17cbc 100644
--- a/backend/app/db.py
+++ b/backend/app/db.py
@@ -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
diff --git a/backend/app/services/pomodoro.py b/backend/app/services/pomodoro.py
index 11df17e..bb13500 100644
--- a/backend/app/services/pomodoro.py
+++ b/backend/app/services/pomodoro.py
@@ -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:
diff --git a/backend/app/services/settings_service.py b/backend/app/services/settings_service.py
index 58793fd..26a193e 100644
--- a/backend/app/services/settings_service.py
+++ b/backend/app/services/settings_service.py
@@ -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
diff --git a/backend/tests/test_db_migration.py b/backend/tests/test_db_migration.py
new file mode 100644
index 0000000..be2ac66
--- /dev/null
+++ b/backend/tests/test_db_migration.py
@@ -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()
diff --git a/backend/tests/test_settings_service.py b/backend/tests/test_settings_service.py
index 70cc0fc..fbe8b78 100644
--- a/backend/tests/test_settings_service.py
+++ b/backend/tests/test_settings_service.py
@@ -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(
diff --git a/desktop/kadence_desktop/controller.py b/desktop/kadence_desktop/controller.py
index 9b0dd6c..55aa010 100644
--- a/desktop/kadence_desktop/controller.py
+++ b/desktop/kadence_desktop/controller.py
@@ -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."""
diff --git a/desktop/kadence_desktop/qml/SettingsPage.qml b/desktop/kadence_desktop/qml/SettingsPage.qml
index 9d802e9..0dc3429 100644
--- a/desktop/kadence_desktop/qml/SettingsPage.qml
+++ b/desktop/kadence_desktop/qml/SettingsPage.qml
@@ -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
- var presets = page.pomodoro.presets.map(function (p) { return Object.assign({}, p) })
- presets[index][key] = value
- controller.patchSettings({ pomodoro: { presets: presets } })
- }
- 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
- }
- })
+ // 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[idx] = Object.assign(presets[idx], patch)
+ controller.patchSettings({ pomodoro: Object.assign({ presets: presets }, patch) })
+ } else {
+ controller.patchSettings({ pomodoro: patch })
+ }
}
component Field: ColumnLayout {
@@ -127,68 +121,32 @@ 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
-
- 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 }
+ text: modelData.name
+ active: controller.activePresetIndex === index
+ onClicked: controller.applyPreset(index)
}
}
+ Label {
+ visible: controller.activePresetIndex === -1
+ text: "Custom"
+ color: Theme.muted
+ font.pixelSize: 12
+ font.family: Theme.sansFamily
+ leftPadding: 6
+ }
+ Item { Layout.fillWidth: true }
}
Flow {
@@ -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 {
diff --git a/desktop/kadence_desktop/qml/TimerPage.qml b/desktop/kadence_desktop/qml/TimerPage.qml
index 2bedd00..e1fd067 100644
--- a/desktop/kadence_desktop/qml/TimerPage.qml
+++ b/desktop/kadence_desktop/qml/TimerPage.qml
@@ -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
diff --git a/desktop/kadence_desktop/qml/components/PresetChip.qml b/desktop/kadence_desktop/qml/components/PresetChip.qml
new file mode 100644
index 0000000..1cb25b1
--- /dev/null
+++ b/desktop/kadence_desktop/qml/components/PresetChip.qml
@@ -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()
+ }
+}
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index 35647ea..bafc77b 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -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;
diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx
index 48a86e3..3ca40e1 100644
--- a/frontend/src/pages/SettingsPage.tsx
+++ b/frontend/src/pages/SettingsPage.tsx
@@ -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 Loading…;
+ // 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) =
/>
+
+ value !== null && applyPreset(value as number)}
+ >
+ {presets.map((preset, index) => (
+
+ {preset.name}
+
+ ))}
+
+ {activePresetIndex === -1 && }
+
{(
[
@@ -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 } })
+ }
/>
))}
diff --git a/frontend/src/pages/TimerPage.tsx b/frontend/src/pages/TimerPage.tsx
index 25e3b0c..c78c371 100644
--- a/frontend/src/pages/TimerPage.tsx
+++ b/frontend/src/pages/TimerPage.tsx
@@ -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 (
<>
+ {timerEnabled && presets.length > 0 && (
+
+ value !== null && void applyPreset(value as number)}
+ >
+ {presets.map((preset, index) => (
+
+ {preset.name}
+
+ ))}
+
+
+ )}
+
{current && timerEnabled && (
-
+
void call("/api/timer/extend", { minutes: 5 })}>