From 772fdbfded1ba7685bddaa7e1feabe0920065b4a Mon Sep 17 00:00:00 2001 From: "Timur Kh." Date: Mon, 19 Oct 2026 11:54:15 +0300 Subject: [PATCH] add blocker presets --- backend/app/db.py | 29 ++++- backend/app/services/pomodoro.py | 19 +++ backend/app/services/settings_service.py | 10 +- backend/tests/test_db_migration.py | 85 +++++++++++++ backend/tests/test_settings_service.py | 26 +++- desktop/kadence_desktop/controller.py | 40 +++++++ desktop/kadence_desktop/qml/SettingsPage.qml | 112 ++++++------------ desktop/kadence_desktop/qml/TimerPage.qml | 23 ++++ .../qml/components/PresetChip.qml | 39 ++++++ frontend/src/lib/types.ts | 7 ++ frontend/src/pages/SettingsPage.tsx | 65 +++++++++- frontend/src/pages/TimerPage.tsx | 54 ++++++++- 12 files changed, 426 insertions(+), 83 deletions(-) create mode 100644 backend/tests/test_db_migration.py create mode 100644 desktop/kadence_desktop/qml/components/PresetChip.qml 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 })}>